diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md new file mode 100644 index 000000000..f221a339f --- /dev/null +++ b/client/ui/CLAUDE.md @@ -0,0 +1,134 @@ +# NetBird Wails UI — Working Notes + +This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; the React/TS frontend lives in `frontend/`; bindings between them are generated under `frontend/bindings/`. + +## Layout +- `main.go`, `tray*.go`, `grpc.go` — app entry, system tray, daemon gRPC client. +- `services/*.go` — typed Wails services exposed to JS (`Profiles`, `Settings`, `Networks`, `Peers`, `Connection`, `Debug`, `Update`, `Forwarding`). Each method becomes a TS function in `frontend/bindings/.../services/`. +- `frontend/bindings/**` — generated, do not edit by hand. Regen via `wails3 generate bindings -clean=true -ts` (from this dir). Triggered by Go code changes. +- `frontend/src/` — React app. Route table is `app.tsx`. App shell is `layouts/AppLayout.tsx`; context providers live under `modules/*/Context.tsx`. + +## Daemon proto +- Proto source: `../proto/daemon.proto`. Generated Go in `../proto/*.pb.go`. +- Regen: `cd ../proto && protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative daemon.proto` +- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch. +- After proto regen, also regen Wails bindings so the TS layer picks up new fields. + +## Wails Dialogs (frontend, `@wailsio/runtime`) + +The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in +`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`. + +### Message dialogs + +```ts +import { Dialogs } from "@wailsio/runtime"; + +await Dialogs.Info({ Title, Message, Buttons?, Detached? }); +await Dialogs.Warning({ Title, Message, Buttons?, Detached? }); +await Dialogs.Error({ Title, Message, Buttons?, Detached? }); +await Dialogs.Question({ Title, Message, Buttons?, Detached? }); +``` + +All four return `Promise` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses. + +`MessageDialogOptions` fields: +- `Title?: string` — window title (short). +- `Message?: string` — the body text. +- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers. +- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS). + +### File dialogs + +`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc). + +### Per-OS behavior + +| Platform | Behavior | +|---|---| +| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. | +| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. | +| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. | + +Behavioural notes that affect us: +- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`). +- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed. +- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`. +- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag. + +### Custom dialogs (frameless child windows) + +When the native API isn't enough (rich content, form layout, complex validation), open a regular Wails window with dialog-like options. This is done on the **Go side** — `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Key options: +- `Parent` — attach to a parent so OS treats it as a child. +- `AlwaysOnTop: true` — float above the parent. +- `Frameless: true` — no titlebar/chrome. +- `Resizable: false` — fixed-size dialog feel. +- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`. + +Modal behavior is achieved by calling `parent.SetEnabled(false)` and restoring with `parent.SetEnabled(true)` in `dialog.OnClose`. Communicate results via Wails events (`app.Event.On(...)`, `Events.Emit(...)` on the frontend) or a Go channel. + +We are **not currently using custom dialogs** in this repo — the in-app modals (`NewProfileDialog`, etc.) are Radix `Dialog` primitives inside the main webview, which is fine for most flows. Reach for a custom OS window only when content must escape the main window (e.g. a separate auth window) or when modality across windows matters. + +## Conventions in this codebase + +### Errors → native dialogs + +We surface user-actionable errors via `Dialogs.Error` rather than red inline text. This started with the profile selector and applies broadly to operation failures (config save, profile switch, debug bundle, update, etc.). + +Pattern: +```ts +try { + await SomeSvc.Operation(...); +} catch (e) { + await Dialogs.Error({ + Title: "Operation Failed", // short, action-named + Message: e instanceof Error ? e.message : String(e), + }); +} +``` + +Title rules: +- Action-named, short: "Switch Profile Failed", "Save Settings Failed", "Debug Bundle Failed". +- Not "Error" / "Something went wrong" — the dialog already says that visually. + +When **not** to use a native dialog: +- **Form validation** (`Input.tsx`, URL-format checks, etc.) — inline next to the field. Native dialogs are too heavy for keystroke-driven feedback. +- **Status/result chrome on a dedicated screen** — e.g. the `/update` and `/login` pages can show a brief "Update failed" header *in addition to* the dialog, so the screen isn't blank after dismissal. +- **Transient link errors on the dashboard** (e.g. `link.error` on a management/signal card) — these flap in/out as the daemon recovers; an inline indicator is more appropriate than a dialog. +- **Result notifications inside a success flow** — e.g. "bundle saved but upload failed" can stay inline since the operation otherwise succeeded. + +### Confirmations +Use `Dialogs.Warning` with explicit `Buttons`: +```ts +const r = await Dialogs.Warning({ + Title: "Delete Profile", + Message: `Are you sure you want to delete "${name}"?`, + Buttons: [ + { Label: "Cancel", IsCancel: true }, + { Label: "Delete", IsDefault: true }, + ], +}); +if (r !== "Delete") return; +``` +Compare against the **Label string** returned, not an index. + +### Bindings & types +Always import generated bindings from `@bindings/services` and types from `@bindings/services/models.js`. The path alias is set up in `tsconfig.json` / `vite.config.ts`. + +After editing any `services/*.go` (or the underlying proto), regenerate: +``` +wails3 generate bindings -clean=true -ts +``` + +### Profile context +`modules/profile/ProfileContext.tsx` is the single source of truth for `username`, `activeProfile`, and the `profiles` list. It exposes `switchProfile`, `addProfile`, `removeProfile`, `logoutProfile`, and `refresh`. `switchProfile` mirrors `tray.go`: it always issues `Profiles.Switch`, but only calls `Connection.Down` + `Connection.Up` when the daemon was actively online (status `Connected`/`Connecting`). Calling `Up` on an `Idle`/`NeedsLogin` daemon makes it block on the daemon's internal 50s `waitForUp` and return `DeadlineExceeded`. Callers shouldn't bring the connection up themselves. + +## Build / dev tasks +- `task dev` — Wails dev mode (live reload). +- `task build` — production build for the current OS (Taskfile dispatches to `darwin/`, `linux/`, `windows/`). +- `task generate:bindings` does not exist as a top-level alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. + +## Useful references +- Wails v3 dialog docs: https://v3.wails.io/features/dialogs/message/ and https://v3.wails.io/features/dialogs/custom/ (may 403 from some clients). +- Authoritative TS signatures: `frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`. +- Wails examples: https://github.com/wailsapp/wails/tree/master/v3/examples/dialogs diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts index bb3a7b821..9420137d6 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts @@ -9,6 +9,7 @@ import * as Peers from "./peers.js"; import * as Profiles from "./profiles.js"; import * as Settings from "./settings.js"; import * as Update from "./update.js"; +import * as Windows from "./windows.js"; export { Connection, Debug, @@ -17,7 +18,8 @@ export { Peers, Profiles, Settings, - Update + Update, + Windows }; export { diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 1502a57b5..7377b19d3 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -3,15 +3,16 @@ import ReactDOM from "react-dom/client"; import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import QuickActions from "@/screens/QuickActions.tsx"; -import LoginUrl from "@/pages/LoginUrl.tsx"; import SessionExpired from "@/pages/SessionExpired.tsx"; import Update from "@/screens/Update.tsx"; import { AppLayout } from "@/layouts/AppLayout.tsx"; +import { SettingsLayout } from "@/layouts/SettingsLayout.tsx"; import { Main } from "@/layouts/Main.tsx"; import { Settings } from "@/modules/settings/Settings.tsx"; import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; +import Login from "@/pages/Login.tsx"; welcome(); @@ -21,12 +22,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> - } /> + } /> } /> } /> + }> + } /> + }> } /> - } /> } diff --git a/client/ui/frontend/src/assets/screens/advanced.png b/client/ui/frontend/src/assets/screens/advanced.png deleted file mode 100644 index 834ab6f89..000000000 Binary files a/client/ui/frontend/src/assets/screens/advanced.png and /dev/null differ diff --git a/client/ui/frontend/src/assets/screens/simple.png b/client/ui/frontend/src/assets/screens/simple.png deleted file mode 100644 index 0a0ede5d1..000000000 Binary files a/client/ui/frontend/src/assets/screens/simple.png and /dev/null differ diff --git a/client/ui/frontend/src/components/NetBirdConnectToggle.tsx b/client/ui/frontend/src/components/NetBirdConnectToggle.tsx index 29fff6f0f..cd2fde6e8 100644 --- a/client/ui/frontend/src/components/NetBirdConnectToggle.tsx +++ b/client/ui/frontend/src/components/NetBirdConnectToggle.tsx @@ -18,9 +18,10 @@ type NetBirdConnectToggleProps = { state: ConnectionState; size?: number; onClick?: () => void; + disabled?: boolean; }; -export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConnectToggleProps) => { +export const NetBirdConnectToggle = ({ state, size = 140, onClick, disabled }: NetBirdConnectToggleProps) => { const [visualState, setVisualState] = useState(state); useEffect(() => { @@ -28,9 +29,10 @@ export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConn }, [state]); const handleClick = () => { + if (disabled) return; if (visualState === ConnectionState.Connected) { setVisualState(ConnectionState.Disconnecting); - } else { + } else if (visualState === ConnectionState.Disconnected) { setVisualState(ConnectionState.Connecting); } onClick?.(); @@ -46,10 +48,14 @@ export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConn return (
diff --git a/client/ui/frontend/src/components/ProfileSelector.tsx b/client/ui/frontend/src/components/ProfileSelector.tsx index c403f8f06..6c12215b1 100644 --- a/client/ui/frontend/src/components/ProfileSelector.tsx +++ b/client/ui/frontend/src/components/ProfileSelector.tsx @@ -5,89 +5,82 @@ import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Command } from "cmdk"; import { Dialogs } from "@wailsio/runtime"; import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react"; +import type { Profile } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { generateColorFromString } from "@/lib/color"; import { NewProfileDialog } from "@/components/NewProfileDialog"; +import { useProfile } from "@/modules/profile/ProfileContext.tsx"; -export type Profile = { - id: string; - name: string; -}; +const DEFAULT_PROFILE = "default"; -const MOCK_PROFILES: Profile[] = [ - { id: "default", name: "Default Profile" }, - { id: "work", name: "Work" }, - { id: "personal", name: "Personal" }, - { id: "staging", name: "Staging" }, - { id: "production", name: "Production" }, - { id: "dev", name: "Development" }, - { id: "qa", name: "QA Environment" }, - { id: "demo", name: "Demo" }, - { id: "client-acme", name: "Client - ACME" }, - { id: "client-globex", name: "Client - Globex" }, - { id: "client-initech", name: "Client - Initech" }, - { id: "homelab", name: "Homelab" }, - { id: "office-berlin", name: "Office Berlin" }, - { id: "office-sf", name: "Office San Francisco" }, - { id: "office-tokyo", name: "Office Tokyo" }, - { id: "vpn-eu", name: "VPN EU" }, - { id: "vpn-us", name: "VPN US" }, - { id: "vpn-asia", name: "VPN Asia" }, - { id: "test", name: "Test" }, - { id: "sandbox", name: "Sandbox" }, -]; +export const ProfileSelector = () => { + const { + profiles, + activeProfile, + loaded, + switchProfile, + addProfile, + removeProfile, + logoutProfile, + } = useProfile(); -type Props = { - email?: string; -}; - -export const ProfileSelector = ({ email = "" }: Props) => { - const [profiles, setProfiles] = useState(MOCK_PROFILES); - const [selectedId, setSelectedId] = useState(MOCK_PROFILES[0].id); const [open, setOpen] = useState(false); const [newOpen, setNewOpen] = useState(false); + const [busy, setBusy] = useState(false); - const selected = profiles.find((p) => p.id === selectedId) ?? profiles[0]; + const selected = + profiles.find((p) => p.name === activeProfile) ?? + profiles.find((p) => p.isActive) ?? + profiles[0]; const sorted = [...profiles].sort((a, b) => a.name.localeCompare(b.name)); - const handleSelect = (id: string) => { - setSelectedId(id); - setOpen(false); + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await Dialogs.Error({ + Title: title, + Message: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } }; - const handleDeregister = async (id: string) => { - const profile = profiles.find((p) => p.id === id); - if (!profile) return; + const handleSelect = (name: string) => { + setOpen(false); + if (name === activeProfile) return; + void guarded("Switch Profile Failed", () => switchProfile(name)); + }; + + const handleDeregister = async (name: string) => { const result = await Dialogs.Warning({ Title: "Deregister Profile", - Message: `Are you sure you want to deregister "${profile.name}"? You will need to log in again to use it.`, + Message: `Are you sure you want to deregister "${name}"? You will need to log in again to use it.`, Buttons: [ { Label: "Cancel", IsCancel: true }, { Label: "Deregister", IsDefault: true }, ], }); if (result !== "Deregister") return; - console.log("Deregister profile", id); + void guarded("Deregister Profile Failed", () => logoutProfile(name)); }; - const handleDelete = async (id: string) => { - const profile = profiles.find((p) => p.id === id); - if (!profile) return; + const handleDelete = async (name: string) => { + if (name === DEFAULT_PROFILE) return; const result = await Dialogs.Warning({ Title: "Delete Profile", - Message: `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, + Message: `Are you sure you want to delete "${name}"? This action cannot be undone.`, Buttons: [ { Label: "Cancel", IsCancel: true }, { Label: "Delete", IsDefault: true }, ], }); if (result !== "Delete") return; - setProfiles((prev) => prev.filter((p) => p.id !== id)); - if (selectedId === id) { - const remaining = profiles.filter((p) => p.id !== id); - if (remaining.length > 0) setSelectedId(remaining[0].id); - } + void guarded("Delete Profile Failed", () => removeProfile(name)); }; const handleNewProfile = () => { @@ -96,12 +89,11 @@ export const ProfileSelector = ({ email = "" }: Props) => { }; const handleCreateProfile = (name: string) => { - const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`; - setProfiles((prev) => [...prev, { id, name }]); - setSelectedId(id); + void guarded("Create Profile Failed", () => addProfile(name)); }; - const initial = selected?.name.charAt(0).toUpperCase() ?? "?"; + const displayName = selected?.name ?? (loaded ? "No profile" : "Loading..."); + const initial = (selected?.name ?? "?").charAt(0).toUpperCase(); const initialColor = generateColorFromString(selected?.name); return ( @@ -116,27 +108,20 @@ export const ProfileSelector = ({ email = "" }: Props) => { >
{initial}
- {selected?.name ?? "No profile"} + {displayName} - {email && ( - - {email} - - )}
@@ -196,12 +181,13 @@ export const ProfileSelector = ({ email = "" }: Props) => { {sorted.map((profile) => ( handleSelect(profile.id)} - onDeregister={() => handleDeregister(profile.id)} - onDelete={() => handleDelete(profile.id)} + selected={profile.name === activeProfile} + onSelect={() => handleSelect(profile.name)} + onDeregister={() => handleDeregister(profile.name)} + onDelete={() => handleDelete(profile.name)} + deletable={profile.name !== DEFAULT_PROFILE} /> ))} @@ -255,9 +241,17 @@ type ProfileRowProps = { onSelect: () => void; onDeregister: () => void; onDelete: () => void; + deletable: boolean; }; -const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: ProfileRowProps) => { +const ProfileRow = ({ + profile, + selected, + onSelect, + onDeregister, + onDelete, + deletable, +}: ProfileRowProps) => { const [menuOpen, setMenuOpen] = useState(false); const initial = profile.name.charAt(0).toUpperCase(); const initialColor = generateColorFromString(profile.name); @@ -265,7 +259,6 @@ const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: Pro return ( onSelect()} className={cn( "group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none", @@ -338,14 +331,19 @@ const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: Pro Deregister { e.preventDefault(); + if (!deletable) return; onDelete(); setMenuOpen(false); }} className={cn( "flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium", - "text-xs text-red-500 data-[highlighted]:bg-nb-gray-850", + "text-xs data-[highlighted]:bg-nb-gray-850", + deletable + ? "text-red-500" + : "text-nb-gray-500 cursor-not-allowed", )} > diff --git a/client/ui/frontend/src/components/ToggleSwitch.tsx b/client/ui/frontend/src/components/ToggleSwitch.tsx index 1b2421437..cfa627244 100644 --- a/client/ui/frontend/src/components/ToggleSwitch.tsx +++ b/client/ui/frontend/src/components/ToggleSwitch.tsx @@ -37,7 +37,7 @@ const switchVariants = cva("", { "thumb-size": { default: "h-5 w-5 data-[state=checked]:translate-x-5", small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]", - large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[30px]", + large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[34px]", }, }, }); diff --git a/client/ui/frontend/src/hooks/useStatus.ts b/client/ui/frontend/src/hooks/useStatus.ts index 2afee0413..d7768733e 100644 --- a/client/ui/frontend/src/hooks/useStatus.ts +++ b/client/ui/frontend/src/hooks/useStatus.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Events } from "@wailsio/runtime"; import { Peers } from "@bindings/services"; import type { Status } from "@bindings/services/models.js"; @@ -6,20 +6,30 @@ import type { Status } from "@bindings/services/models.js"; const EVENT_STATUS = "netbird:status"; // useStatus loads the current daemon status once and re-renders whenever the -// peers service emits a fresh snapshot over the Wails event bus. -export function useStatus(): { status: Status | null; error: string | null } { +// peers service emits a fresh snapshot over the Wails event bus. Callers can +// also force a manual refresh (e.g. right after Connection.Up/Down) so the +// view never lags behind a user action even if the daemon event stream is +// briefly silent. +export function useStatus(): { + status: Status | null; + error: string | null; + refresh: () => Promise; +} { const [status, setStatus] = useState(null); const [error, setError] = useState(null); + const refresh = useCallback(async () => { + try { + const s = await Peers.Get(); + setStatus(s); + setError(null); + } catch (e) { + setError(String(e)); + } + }, []); + useEffect(() => { - let cancelled = false; - Peers.Get() - .then((s) => { - if (!cancelled) setStatus(s); - }) - .catch((e: unknown) => { - if (!cancelled) setError(String(e)); - }); + void refresh(); const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { setStatus(ev.data); @@ -27,10 +37,9 @@ export function useStatus(): { status: Status | null; error: string | null } { }); return () => { - cancelled = true; off(); }; - }, []); + }, [refresh]); - return { status, error }; + return { status, error, refresh }; } diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index 09dd5f5f1..2e733bd77 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from "react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Dialogs } from "@wailsio/runtime"; +import { Connection } from "@bindings/services"; import { ConnectionState } from "@/components/NetBirdConnectToggle.tsx"; import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; +import { useStatus } from "@/hooks/useStatus"; +import { useProfile } from "@/modules/profile/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; -const CONNECT_DURATION_MS = 1500; -const DISCONNECT_DURATION_MS = 800; - const STATUS_LABEL: Record = { [ConnectionState.Disconnected]: "Disconnected", [ConnectionState.Connecting]: "Connecting...", @@ -14,46 +16,98 @@ const STATUS_LABEL: Record = { [ConnectionState.Disconnecting]: "Disconnecting...", }; +const errorMessage = (e: unknown) => + e instanceof Error ? e.message : String(e); + export const ConnectionStatusSwitch = () => { - const [state, setState] = useState(ConnectionState.Disconnected); - const timerRef = useRef | null>(null); + const { status, refresh } = useStatus(); + const { activeProfile, username } = useProfile(); + const navigate = useNavigate(); - useEffect( - () => () => { - if (timerRef.current) clearTimeout(timerRef.current); - }, - [], - ); + const daemonState = status?.status ?? "Idle"; + const needsLogin = + daemonState === "NeedsLogin" || + daemonState === "SessionExpired" || + daemonState === "LoginFailed"; + const unreachable = daemonState === "DaemonUnavailable"; - const transition = (next: ConnectionState, after: ConnectionState, delay: number) => { - if (timerRef.current) clearTimeout(timerRef.current); - setState(next); - timerRef.current = setTimeout(() => { - setState(after); - timerRef.current = null; - }, delay); + // Tracks an in-flight user action (Up/Down RPC + refresh) so we can show a + // transitional label and disable the switch without lying about the + // daemon's actual state. + const [action, setAction] = useState<"connect" | "disconnect" | null>(null); + + const connState: ConnectionState = useMemo(() => { + if (action === "disconnect" && daemonState === "Connected") { + return ConnectionState.Disconnecting; + } + if (action === "connect" && daemonState !== "Connected") { + return ConnectionState.Connecting; + } + switch (daemonState) { + case "Connected": + return ConnectionState.Connected; + case "Connecting": + return ConnectionState.Connecting; + default: + return ConnectionState.Disconnected; + } + }, [daemonState, action]); + + const connect = async () => { + setAction("connect"); + try { + await Connection.Up({ + profileName: activeProfile, + username, + }); + } catch (e) { + await Dialogs.Error({ + Title: "Connect Failed", + Message: errorMessage(e), + }); + } finally { + await refresh(); + setAction(null); + } }; - const connect = () => - transition(ConnectionState.Connecting, ConnectionState.Connected, CONNECT_DURATION_MS); - const disconnect = () => - transition( - ConnectionState.Disconnecting, - ConnectionState.Disconnected, - DISCONNECT_DURATION_MS, - ); + const disconnect = async () => { + setAction("disconnect"); + try { + await Connection.Down(); + } catch (e) { + await Dialogs.Error({ + Title: "Disconnect Failed", + Message: errorMessage(e), + }); + } finally { + await refresh(); + setAction(null); + } + }; const handleSwitch = (next: boolean) => { - if (next) { - if (state === ConnectionState.Disconnected) connect(); - } else if (state === ConnectionState.Connected) { - disconnect(); + if (unreachable || action !== null) return; + if (needsLogin) { + navigate("/login"); + return; + } + if (next && connState === ConnectionState.Disconnected) { + void connect(); + } else if (!next && connState === ConnectionState.Connected) { + void disconnect(); } }; const isTransitioning = - state === ConnectionState.Connecting || state === ConnectionState.Disconnecting; - const isOn = state === ConnectionState.Connected || state === ConnectionState.Connecting; + connState === ConnectionState.Connecting || + connState === ConnectionState.Disconnecting; + const isOn = + connState === ConnectionState.Connected || + connState === ConnectionState.Connecting; + const showLocal = connState === ConnectionState.Connected; + const fqdn = status?.local.fqdn || ""; + const ip = status?.local.ip || ""; return (
@@ -68,8 +122,11 @@ export const ConnectionStatusSwitch = () => { size={"large"} checked={isOn} onCheckedChange={handleSwitch} - disabled={isTransitioning} - className={cn(isTransitioning && "opacity-80")} + disabled={isTransitioning || unreachable} + className={cn( + unreachable && "opacity-80", + isTransitioning && "animate-pulse", + )} />
@@ -78,23 +135,27 @@ export const ConnectionStatusSwitch = () => { "text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300" } > - {STATUS_LABEL[state]} + {unreachable + ? "Daemon unavailable" + : needsLogin + ? "Login required" + : STATUS_LABEL[connState]}

- peer-hostname.netbird.cloud + {fqdn || " "}

- 192.168.0.1 + {ip || " "}

diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index ba33df867..502d143ee 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react"; import { Window } from "@wailsio/runtime"; +import { Windows as WindowsSvc } from "@bindings/services"; import { ProfileSelector } from "@/components/ProfileSelector.tsx"; import { IconButton } from "@/components/IconButton.tsx"; import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; @@ -13,11 +13,7 @@ const WINDOW_HEIGHT = 615; const EXPANDED_THRESHOLD = 500; export const Header = () => { - const navigate = useNavigate(); - const location = useLocation(); - const isSettingsPage = location.pathname.startsWith("/settings"); const { showProfileSelector, showSettingsButton, expanded, setField } = useAppearance(); - const showSettings = showSettingsButton || isSettingsPage; const didInitialResize = useRef(false); useEffect(() => { @@ -28,6 +24,12 @@ export const Header = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!didInitialResize.current) return; + const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; + void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); + }, [expanded]); + useEffect(() => { const onResize = () => { const isWide = window.innerWidth >= EXPANDED_THRESHOLD; @@ -44,6 +46,10 @@ export const Header = () => { void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); }; + const openSettings = () => { + void WindowsSvc.OpenSettings().catch(() => {}); + }; + return (
{ > {showProfileSelector && (
- +
)} @@ -61,15 +67,8 @@ export const Header = () => { icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon} onClick={togglePanel} /> - {showSettings && ( - navigate(isSettingsPage ? "/" : "/settings")} - className={cn( - isSettingsPage && - "bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200", - )} - /> + {showSettingsButton && ( + )}
); diff --git a/client/ui/frontend/src/layouts/SettingsLayout.tsx b/client/ui/frontend/src/layouts/SettingsLayout.tsx new file mode 100644 index 000000000..e56dcd6fe --- /dev/null +++ b/client/ui/frontend/src/layouts/SettingsLayout.tsx @@ -0,0 +1,36 @@ +import { Outlet } from "react-router-dom"; +import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx"; +import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; +import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; +import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; + +// SettingsLayout wraps the Settings screen for use inside its own dedicated +// window. Same provider stack as AppLayout but without the main Header — the +// settings window has its own native title bar and doesn't show the profile +// selector / panel toggle / settings icon. +// +// The 38px placeholder strip at the top accounts for the macOS +// `MacTitleBarHiddenInset` setting in services/windows.go: the native title +// bar is invisible but the traffic-light buttons still float in the top-left +// corner. Without this strip the buttons would overlap the settings content. +// The strip is `wails-draggable` so users can move the window by dragging it. +export const SettingsLayout = () => { + return ( +
+ + + + +
+ + + + + +
+ ); +}; diff --git a/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx b/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx index ae72839d9..2414954d3 100644 --- a/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx +++ b/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx @@ -8,11 +8,9 @@ import { type ReactNode, } from "react"; -export type AppearanceView = "default" | "advanced"; export type ConnectionLayout = "default" | "switch"; export type AppearanceState = { - view: AppearanceView; connectionLayout: ConnectionLayout; expanded: boolean; showPeersNav: boolean; @@ -25,7 +23,6 @@ export type AppearanceState = { const STORAGE_KEY = "netbird:appearance"; const DEFAULTS: AppearanceState = { - view: "default", connectionLayout: "default", expanded: true, showPeersNav: true, @@ -47,7 +44,6 @@ const readStored = (): AppearanceState => { }; type AppearanceContextValue = AppearanceState & { - setView: (v: AppearanceView) => void; setField: (k: K, v: AppearanceState[K]) => void; }; @@ -79,13 +75,9 @@ export const AppearanceProvider = ({ children }: { children: ReactNode }) => { [], ); - const setView = useCallback((v: AppearanceView) => { - setState((s) => ({ ...s, view: v })); - }, []); - const value = useMemo( - () => ({ ...state, setView, setField }), - [state, setView, setField], + () => ({ ...state, setField }), + [state, setField], ); return ( diff --git a/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts b/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts index dc0fdfbab..1ad484fa4 100644 --- a/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts +++ b/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { Dialogs } from "@wailsio/runtime"; import { Connection as ConnectionSvc, Debug as DebugSvc, @@ -19,8 +20,7 @@ export type DebugStage = | { kind: "bundling" } | { kind: "uploading" } | { kind: "cancelling" } - | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean } - | { kind: "error"; message: string }; + | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }; const sleep = (ms: number, signal: AbortSignal) => new Promise((resolve, reject) => { @@ -53,10 +53,7 @@ export const useDebugBundle = () => { const [lastBundlePath, setLastBundlePath] = useState(""); const abortRef = useRef(null); - const isRunning = - stage.kind !== "idle" && - stage.kind !== "done" && - stage.kind !== "error"; + const isRunning = stage.kind !== "idle" && stage.kind !== "done"; const reset = () => setStage({ kind: "idle" }); @@ -157,7 +154,11 @@ export const useDebugBundle = () => { setStage({ kind: "idle" }); return; } - setStage({ kind: "error", message: String(e) }); + setStage({ kind: "idle" }); + await Dialogs.Error({ + Title: "Debug Bundle Failed", + Message: e instanceof Error ? e.message : String(e), + }); } finally { if (abortRef.current === ctrl) abortRef.current = null; } diff --git a/client/ui/frontend/src/modules/profile/ProfileContext.tsx b/client/ui/frontend/src/modules/profile/ProfileContext.tsx index 064c1a4df..5027448e2 100644 --- a/client/ui/frontend/src/modules/profile/ProfileContext.tsx +++ b/client/ui/frontend/src/modules/profile/ProfileContext.tsx @@ -6,15 +6,20 @@ import { useState, type ReactNode, } from "react"; -import { Profiles as ProfilesSvc } from "@bindings/services"; +import { Dialogs } from "@wailsio/runtime"; +import { Connection, Peers, Profiles as ProfilesSvc } from "@bindings/services"; +import type { Profile } from "@bindings/services/models.js"; type ProfileContextValue = { username: string; activeProfile: string; + profiles: Profile[]; loaded: boolean; - error: string | null; refresh: () => Promise; switchProfile: (name: string) => Promise; + addProfile: (name: string) => Promise; + removeProfile: (name: string) => Promise; + logoutProfile: (name: string) => Promise; }; const ProfileContext = createContext(null); @@ -30,18 +35,24 @@ export const useProfile = () => { export const ProfileProvider = ({ children }: { children: ReactNode }) => { const [username, setUsername] = useState(""); const [activeProfile, setActiveProfile] = useState(""); + const [profiles, setProfiles] = useState([]); const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const u = await ProfilesSvc.Username(); - const active = await ProfilesSvc.GetActive(); + const [active, list] = await Promise.all([ + ProfilesSvc.GetActive(), + ProfilesSvc.List(u), + ]); setUsername(u); setActiveProfile(active.profileName || "default"); - setError(null); + setProfiles(list); } catch (e) { - setError(String(e)); + await Dialogs.Error({ + Title: "Load Profiles Failed", + Message: e instanceof Error ? e.message : String(e), + }); } finally { setLoaded(true); } @@ -53,10 +64,53 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { const switchProfile = useCallback( async (name: string) => { + // Mirror tray.go switchProfile: only reconnect when the daemon was + // actively online. Calling Up on an Idle/NeedsLogin daemon makes + // the daemon wait 50s on its internal waitForUp and return + // DeadlineExceeded. + let wasActive = false; + try { + const prev = await Peers.Get(); + const s = (prev?.status ?? "").toLowerCase(); + wasActive = s === "connected" || s === "connecting"; + } catch { + wasActive = false; + } + await ProfilesSvc.Switch({ profileName: name, username }); - setActiveProfile(name); + + if (wasActive) { + await Connection.Down(); + await Connection.Up({ profileName: name, username }); + } + + await refresh(); }, - [username], + [username, refresh], + ); + + const addProfile = useCallback( + async (name: string) => { + await ProfilesSvc.Add({ profileName: name, username }); + await refresh(); + }, + [username, refresh], + ); + + const removeProfile = useCallback( + async (name: string) => { + await ProfilesSvc.Remove({ profileName: name, username }); + await refresh(); + }, + [username, refresh], + ); + + const logoutProfile = useCallback( + async (name: string) => { + await Connection.Logout({ profileName: name, username }); + await refresh(); + }, + [username, refresh], ); return ( @@ -64,10 +118,13 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { value={{ username, activeProfile, + profiles, loaded, - error, refresh, switchProfile, + addProfile, + removeProfile, + logoutProfile, }} > {children} diff --git a/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx b/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx index e740bf424..62b49cee1 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx @@ -1,26 +1,9 @@ import FancyToggleSwitch from "@/components/FancyToggleSwitch"; -import { CardSelect } from "@/components/CardSelect.tsx"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { - useAppearance, - type AppearanceView, -} from "@/modules/appearance/AppearanceContext.tsx"; -import simpleScreen from "@/assets/screens/simple.png"; -import advancedScreen from "@/assets/screens/advanced.png"; - -const ScreenPreview = ({ src, alt }: { src: string; alt: string }) => ( - {alt} -); +import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; export function SettingsAppearance() { const { - view, - setView, showPeersNav, showResourcesNav, showExitNodeNav, @@ -30,59 +13,37 @@ export function SettingsAppearance() { } = useAppearance(); return ( - <> - - setView(v as AppearanceView)} - > - } - /> - } - /> - - - - - setField("showPeersNav", v)} - label={"Peers"} - helpText={"Show the Peers item in the side navigation."} - /> - setField("showResourcesNav", v)} - label={"Resources"} - helpText={"Show the Resources item in the side navigation."} - /> - setField("showExitNodeNav", v)} - label={"Exit Node"} - helpText={"Show the active exit node in the side navigation."} - /> - setField("showProfileSelector", v)} - label={"Profile Selector"} - helpText={"Show the profile selector in the header."} - /> - setField("showSettingsButton", v)} - label={"Settings Button"} - helpText={"Show the settings button in the header."} - /> - - + + setField("showPeersNav", v)} + label={"Peers"} + helpText={"Show the Peers item in the side navigation."} + /> + setField("showResourcesNav", v)} + label={"Resources"} + helpText={"Show the Resources item in the side navigation."} + /> + setField("showExitNodeNav", v)} + label={"Exit Node"} + helpText={"Show the active exit node in the side navigation."} + /> + setField("showProfileSelector", v)} + label={"Profile Selector"} + helpText={"Show the profile selector in the header."} + /> + setField("showSettingsButton", v)} + label={"Settings Button"} + helpText={"Show the settings button in the header."} + /> + ); } diff --git a/client/ui/frontend/src/modules/settings/SettingsContext.tsx b/client/ui/frontend/src/modules/settings/SettingsContext.tsx index 350113733..98c90a924 100644 --- a/client/ui/frontend/src/modules/settings/SettingsContext.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsContext.tsx @@ -7,11 +7,15 @@ import { useState, type ReactNode, } from "react"; +import { Dialogs } from "@wailsio/runtime"; import { Settings as SettingsSvc } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import { useProfile } from "@/modules/profile/ProfileContext.tsx"; import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx"; +const errorMessage = (e: unknown) => + e instanceof Error ? e.message : String(e); + const SAVE_DEBOUNCE_MS = 400; type SettingsContextValue = { @@ -35,7 +39,6 @@ export const useSettings = () => { const useSettingsState = () => { const { username, activeProfile, loaded: profileLoaded } = useProfile(); const [config, setConfig] = useState(null); - const [error, setError] = useState(null); const saveTimer = useRef | null>(null); useEffect(() => { @@ -47,9 +50,11 @@ const useSettingsState = () => { username, }); setConfig(c); - setError(null); } catch (e) { - setError(String(e)); + await Dialogs.Error({ + Title: "Load Settings Failed", + Message: errorMessage(e), + }); } })(); }, [profileLoaded, activeProfile, username]); @@ -75,9 +80,11 @@ const useSettingsState = () => { profileName: activeProfile, username, }); - setError(null); } catch (e) { - setError(String(e)); + await Dialogs.Error({ + Title: "Save Settings Failed", + Message: errorMessage(e), + }); } }, [activeProfile, username], @@ -135,33 +142,29 @@ const useSettingsState = () => { [config, save], ); - return { config, error, setField, saveField, saveFields, saveNow }; + return { config, setField, saveField, saveFields, saveNow }; }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, error, setField, saveField, saveFields, saveNow } = useSettingsState(); + const { config, setField, saveField, saveFields, saveNow } = useSettingsState(); - // TODO: Better displaying of errors return ( - <> - {error &&

{error}

} -
- {!config ? ( - - ) : ( - - {children} - - )} -
- +
+ {!config ? ( + + ) : ( + + {children} + + )} +
); }; diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx index 46523d1a1..18cb4da00 100644 --- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -31,8 +31,14 @@ export function SettingsTroubleshooting() { reset, } = useDebugBundleContext(); - if (stage.kind === "done" || stage.kind === "error") { - return ; + if (stage.kind === "done") { + return ( + + ); } if (stage.kind !== "idle") { return ; @@ -127,30 +133,6 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () ); } -function ResultSection({ - stage, - onClose, -}: { - stage: Extract; - onClose: () => void; -}) { - if (stage.kind === "error") { - return ( - - Close - - } - /> - ); - } - return ; -} - function DoneResult({ result, uploaded, diff --git a/client/ui/frontend/src/modules/settings/useManagementUrl.ts b/client/ui/frontend/src/modules/settings/useManagementUrl.ts index 497ee6c94..187798b38 100644 --- a/client/ui/frontend/src/modules/settings/useManagementUrl.ts +++ b/client/ui/frontend/src/modules/settings/useManagementUrl.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Dialogs } from "@wailsio/runtime"; import { useSettings } from "@/modules/settings/SettingsContext.tsx"; export enum ManagementMode { @@ -52,13 +53,29 @@ export function useManagementUrl() { }, [config.managementUrl]); const setMode = (next: ManagementMode) => { - setModeState(next); if ( next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL ) { - void saveField("managementUrl", CLOUD_MANAGEMENT_URL); + // Switching from a self-hosted management server to NetBird Cloud + // re-points the client at a different deployment and forces a + // reconnect/re-login. Confirm before applying. + void Dialogs.Warning({ + Title: "Switch to NetBird Cloud?", + Message: + "This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.", + Buttons: [ + { Label: "Cancel", IsCancel: true, IsDefault: true }, + { Label: "Switch to Cloud" }, + ], + }).then((result) => { + if (result !== "Switch to Cloud") return; + setModeState(ManagementMode.Cloud); + void saveField("managementUrl", CLOUD_MANAGEMENT_URL); + }); + return; } + setModeState(next); }; const normalizedUrl = normalizeManagementUrl(url); diff --git a/client/ui/frontend/src/pages/LoginUrl.tsx b/client/ui/frontend/src/pages/LoginUrl.tsx deleted file mode 100644 index 7d9fdd98e..000000000 --- a/client/ui/frontend/src/pages/LoginUrl.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from "react"; -import { ExternalLink } from "lucide-react"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; -import { Button } from "../components/Button"; - -export default function LoginUrl() { - const [url, setUrl] = useState(""); - - useEffect(() => { - const params = new URLSearchParams(window.location.hash.split("?")[1] ?? ""); - setUrl(params.get("url") ?? ""); - }, []); - - if (!url) { - return ( -
- No login URL provided. -
- ); - } - - return ( -
-

Continue in your browser

-

- Open the following URL to finish signing in. -

- -

{url}

-
- ); -} diff --git a/client/ui/frontend/src/screens/Status.tsx b/client/ui/frontend/src/screens/Status.tsx index 4c0352f46..d47217372 100644 --- a/client/ui/frontend/src/screens/Status.tsx +++ b/client/ui/frontend/src/screens/Status.tsx @@ -12,13 +12,16 @@ export default function Status() { const { status, error } = useStatus(); const navigate = useNavigate(); - const connState = status?.status ?? "Disconnected"; + const connState = status?.status ?? "Idle"; const connected = connState === "Connected"; const connecting = connState === "Connecting"; // The daemon reports "NeedsLogin" on a fresh install or after a session // expires; "SessionExpired" once a previously good session lapses. In both // cases Connect would fail without a fresh SSO login. - const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired"; + const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired" || connState === "LoginFailed"; + // DaemonUnavailable is the synthetic status the UI emits when the gRPC + // socket is unreachable; Up/Down would just error, so the toggle is dead. + const unreachable = connState === "DaemonUnavailable"; // Always offer Login while we aren't Connected — including Connecting, // because a stuck Login on the daemon leaves us in Connecting forever and // the user has no other way out. Disconnect is the manual unstick path. @@ -32,7 +35,17 @@ export default function Status() { const login = () => navigate("/login"); const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error); const disconnect = () => Connection.Down().catch(console.error); - const toggleConnection = () => (connected ? disconnect() : connect()); + const toggleConnection = () => { + if (needsLogin) { + navigate("/login"); + return; + } + if (connected) { + disconnect(); + return; + } + connect(); + }; return (
@@ -107,8 +120,12 @@ export default function Status() { })()} -
- +
+
); diff --git a/client/ui/frontend/src/screens/Update.tsx b/client/ui/frontend/src/screens/Update.tsx index ed7473503..2f2632542 100644 --- a/client/ui/frontend/src/screens/Update.tsx +++ b/client/ui/frontend/src/screens/Update.tsx @@ -1,22 +1,31 @@ import { useEffect, useState } from "react"; import { Loader2 } from "lucide-react"; +import { Dialogs } from "@wailsio/runtime"; import { Update as UpdateSvc } from "@bindings/services"; const TIMEOUT_MS = 15 * 60 * 1000; +const showError = (message: string) => + Dialogs.Error({ Title: "Update Failed", Message: message }); + export default function Update() { const [done, setDone] = useState(false); - const [error, setError] = useState(null); + const [failed, setFailed] = useState(false); useEffect(() => { let cancelled = false; - UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e))); + UpdateSvc.Trigger().catch((e) => { + if (cancelled) return; + setFailed(true); + void showError(e instanceof Error ? e.message : String(e)); + }); const start = Date.now(); const timer = setInterval(async () => { if (Date.now() - start > TIMEOUT_MS) { - setError("Update timed out."); clearInterval(timer); + setFailed(true); + void showError("Update timed out."); return; } try { @@ -25,8 +34,9 @@ export default function Update() { setDone(true); clearInterval(timer); } else if (r.errorMsg) { - setError(r.errorMsg); clearInterval(timer); + setFailed(true); + void showError(r.errorMsg); } } catch { // installer not finished yet @@ -44,8 +54,8 @@ export default function Update() {
{done ? (

Update complete

- ) : error ? ( -

{error}

+ ) : failed ? ( +

Update failed

) : ( <> diff --git a/client/ui/main.go b/client/ui/main.go index 2912eafae..03f10a00a 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -114,23 +114,14 @@ func main() { update := services.NewUpdate(conn) notifier := notifications.New() - app.RegisterService(application.NewService(connection)) - app.RegisterService(application.NewService(settings)) - app.RegisterService(application.NewService(services.NewNetworks(conn))) - app.RegisterService(application.NewService(services.NewForwarding(conn))) - app.RegisterService(application.NewService(profiles)) - app.RegisterService(application.NewService(services.NewDebug(conn))) - app.RegisterService(application.NewService(update)) - app.RegisterService(application.NewService(peers)) - app.RegisterService(application.NewService(notifier)) - window := app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "NetBird", - Width: 925, - Height: 615, - Hidden: true, - BackgroundColour: application.NewRGB(24, 26, 29), - URL: "/", + Title: "NetBird", + Width: 925, + Height: 615, + Hidden: true, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: "/", + MaximiseButtonState: application.ButtonHidden, Mac: application.MacWindow{ InvisibleTitleBarHeight: 38, Backdrop: application.MacBackdropTranslucent, @@ -149,6 +140,23 @@ func main() { window.Hide() }) + // Pre-create the settings window AFTER the main window so the OS treats + // the main window as the primary one. The settings window stays hidden + // until the user clicks the Settings icon — preloading it here keeps the + // first-open instant. + windows := services.NewWindows(app) + + app.RegisterService(application.NewService(connection)) + app.RegisterService(application.NewService(settings)) + app.RegisterService(application.NewService(services.NewNetworks(conn))) + app.RegisterService(application.NewService(services.NewForwarding(conn))) + app.RegisterService(application.NewService(profiles)) + app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(update)) + app.RegisterService(application.NewService(peers)) + app.RegisterService(application.NewService(windows)) + app.RegisterService(application.NewService(notifier)) + // Register an in-process StatusNotifierWatcher so the tray works on // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the // AppIndicator extension) that don't ship one themselves. No-op on diff --git a/client/ui/services/windows.go b/client/ui/services/windows.go new file mode 100644 index 000000000..cc2fc8484 --- /dev/null +++ b/client/ui/services/windows.go @@ -0,0 +1,66 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// Windows opens auxiliary application windows on demand from the frontend. +// The main window is created up-front in main.go; this service is for +// secondary, on-demand surfaces (Settings). +// +// The settings window is created hidden at app startup so its React bundle is +// already loaded by the time the user clicks the Settings icon — OpenSettings +// then just shows and focuses the pre-warmed window. Closing the window hides +// it instead of destroying it, so reopening is also instant. +type Windows struct { + app *application.App + settings *application.WebviewWindow +} + +func NewWindows(app *application.App) *Windows { + w := &Windows{app: app} + w.settings = w.buildSettings() + return w +} + +func (s *Windows) buildSettings() *application.WebviewWindow { + w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "NetBird Settings", + Width: 900, + Height: 640, + Hidden: true, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: "/#/settings", + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + }, + }) + + // Hide instead of close so the React bundle stays warm and the next + // OpenSettings is instant — same trick the main window uses. + w.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + w.Hide() + }) + + return w +} + +// OpenSettings shows the pre-warmed settings window. +func (s *Windows) OpenSettings() { + if s.settings == nil { + return + } + s.settings.Show() + s.settings.Focus() +}