mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-13 20:29:55 +00:00
update stuff
This commit is contained in:
134
client/ui/CLAUDE.md
Normal file
134
client/ui/CLAUDE.md
Normal file
@@ -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<string>` 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/login" element={<LoginUrl />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route path="/session-expired" element={<SessionExpired />} />
|
||||
<Route element={<SettingsLayout />}>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Main />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 202 KiB |
@@ -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 (
|
||||
<div>
|
||||
<motion.button
|
||||
className="rounded-full relative overflow-visible cursor-default outline-none border-none bg-transparent"
|
||||
className={cn(
|
||||
"rounded-full relative overflow-visible outline-none border-none bg-transparent",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
)}
|
||||
style={{ padding }}
|
||||
onClick={handleClick}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
disabled={disabled}
|
||||
whileTap={disabled ? undefined : { scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<OuterRing state={visualState} />
|
||||
|
||||
@@ -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<Profile[]>(MOCK_PROFILES);
|
||||
const [selectedId, setSelectedId] = useState<string>(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<void>) => {
|
||||
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) => {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
|
||||
email ? "h-7 w-7" : "h-6 w-6",
|
||||
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold h-6 w-6",
|
||||
)}
|
||||
style={{ color: initialColor }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-nowrap flex flex-col ml-1 text-left",
|
||||
email ? "mt-1" : "justify-center",
|
||||
)}
|
||||
className={
|
||||
"whitespace-nowrap flex flex-col ml-1 text-left justify-center"
|
||||
}
|
||||
>
|
||||
<span className={"leading-none text-nb-gray-200 font-semibold"}>
|
||||
{selected?.name ?? "No profile"}
|
||||
{displayName}
|
||||
</span>
|
||||
{email && (
|
||||
<span className={"text-[0.73rem] font-normal text-nb-gray-300"}>
|
||||
{email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={"ml-2 mr-2"} />
|
||||
</button>
|
||||
@@ -196,12 +181,13 @@ export const ProfileSelector = ({ email = "" }: Props) => {
|
||||
|
||||
{sorted.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
selected={profile.id === selectedId}
|
||||
onSelect={() => 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}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
@@ -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 (
|
||||
<Command.Item
|
||||
value={profile.name}
|
||||
keywords={[profile.id]}
|
||||
onSelect={() => 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
|
||||
<span>Deregister</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={!deletable}
|
||||
onSelect={(e) => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -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]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
} {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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, string> = {
|
||||
[ConnectionState.Disconnected]: "Disconnected",
|
||||
[ConnectionState.Connecting]: "Connecting...",
|
||||
@@ -14,46 +16,98 @@ const STATUS_LABEL: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnecting]: "Disconnecting...",
|
||||
};
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
|
||||
export const ConnectionStatusSwitch = () => {
|
||||
const [state, setState] = useState<ConnectionState>(ConnectionState.Disconnected);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={"flex flex-col items-center"}>
|
||||
@@ -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]}
|
||||
</h1>
|
||||
<p
|
||||
className={
|
||||
"font-mono text-xs text-nb-gray-300 mt-2 transition-opacity duration-300 " +
|
||||
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
|
||||
}
|
||||
className={cn(
|
||||
"font-mono text-xs leading-tight min-h-[1em] text-nb-gray-300 mt-2 transition-opacity duration-300",
|
||||
showLocal && fqdn ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
peer-hostname.netbird.cloud
|
||||
{fqdn || " "}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
"font-mono text-xs text-nb-gray-300 mt-0.5 transition-opacity duration-300 " +
|
||||
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
|
||||
}
|
||||
className={cn(
|
||||
"font-mono text-xs leading-tight min-h-[1em] text-nb-gray-300 mt-0.5 transition-opacity duration-300",
|
||||
showLocal && ip ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
192.168.0.1
|
||||
{ip || " "}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -53,7 +59,7 @@ export const Header = () => {
|
||||
>
|
||||
{showProfileSelector && (
|
||||
<div className={"ml-20"}>
|
||||
<ProfileSelector email={"eduard@netbird.io"} />
|
||||
<ProfileSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -61,15 +67,8 @@ export const Header = () => {
|
||||
icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon}
|
||||
onClick={togglePanel}
|
||||
/>
|
||||
{showSettings && (
|
||||
<IconButton
|
||||
icon={SettingsIcon}
|
||||
onClick={() => 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 && (
|
||||
<IconButton icon={SettingsIcon} onClick={openSettings} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
36
client/ui/frontend/src/layouts/SettingsLayout.tsx
Normal file
36
client/ui/frontend/src/layouts/SettingsLayout.tsx
Normal file
@@ -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 (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<AppearanceProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<div
|
||||
className={
|
||||
"wails-draggable cursor-default select-none h-[38px] shrink-0"
|
||||
}
|
||||
/>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</AppearanceProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 extends keyof AppearanceState>(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<AppearanceContextValue>(
|
||||
() => ({ ...state, setView, setField }),
|
||||
[state, setView, setField],
|
||||
() => ({ ...state, setField }),
|
||||
[state, setField],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
@@ -53,10 +53,7 @@ export const useDebugBundle = () => {
|
||||
const [lastBundlePath, setLastBundlePath] = useState<string>("");
|
||||
const abortRef = useRef<AbortController | null>(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;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
switchProfile: (name: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<void>;
|
||||
removeProfile: (name: string) => Promise<void>;
|
||||
logoutProfile: (name: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const ProfileContext = createContext<ProfileContextValue | null>(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<Profile[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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}
|
||||
|
||||
@@ -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 }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
className={"h-full w-full object-contain select-none"}
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<SectionGroup title={"View"}>
|
||||
<CardSelect
|
||||
value={view}
|
||||
onChange={(v) => setView(v as AppearanceView)}
|
||||
>
|
||||
<CardSelect.Option
|
||||
value={"default"}
|
||||
title={"Simple"}
|
||||
description={"Streamlined view with essential controls."}
|
||||
preview={<ScreenPreview src={simpleScreen} alt={"Simple view"} />}
|
||||
/>
|
||||
<CardSelect.Option
|
||||
value={"advanced"}
|
||||
title={"Advanced"}
|
||||
description={"All details and power-user options visible."}
|
||||
preview={<ScreenPreview src={advancedScreen} alt={"Advanced view"} />}
|
||||
/>
|
||||
</CardSelect>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Interface"}>
|
||||
<FancyToggleSwitch
|
||||
value={showPeersNav}
|
||||
onChange={(v) => setField("showPeersNav", v)}
|
||||
label={"Peers"}
|
||||
helpText={"Show the Peers item in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showResourcesNav}
|
||||
onChange={(v) => setField("showResourcesNav", v)}
|
||||
label={"Resources"}
|
||||
helpText={"Show the Resources item in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showExitNodeNav}
|
||||
onChange={(v) => setField("showExitNodeNav", v)}
|
||||
label={"Exit Node"}
|
||||
helpText={"Show the active exit node in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showProfileSelector}
|
||||
onChange={(v) => setField("showProfileSelector", v)}
|
||||
label={"Profile Selector"}
|
||||
helpText={"Show the profile selector in the header."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showSettingsButton}
|
||||
onChange={(v) => setField("showSettingsButton", v)}
|
||||
label={"Settings Button"}
|
||||
helpText={"Show the settings button in the header."}
|
||||
/>
|
||||
</SectionGroup>
|
||||
</>
|
||||
<SectionGroup title={"Interface"}>
|
||||
<FancyToggleSwitch
|
||||
value={showPeersNav}
|
||||
onChange={(v) => setField("showPeersNav", v)}
|
||||
label={"Peers"}
|
||||
helpText={"Show the Peers item in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showResourcesNav}
|
||||
onChange={(v) => setField("showResourcesNav", v)}
|
||||
label={"Resources"}
|
||||
helpText={"Show the Resources item in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showExitNodeNav}
|
||||
onChange={(v) => setField("showExitNodeNav", v)}
|
||||
label={"Exit Node"}
|
||||
helpText={"Show the active exit node in the side navigation."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showProfileSelector}
|
||||
onChange={(v) => setField("showProfileSelector", v)}
|
||||
label={"Profile Selector"}
|
||||
helpText={"Show the profile selector in the header."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={showSettingsButton}
|
||||
onChange={(v) => setField("showSettingsButton", v)}
|
||||
label={"Settings Button"}
|
||||
helpText={"Show the settings button in the header."}
|
||||
/>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Config | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<SkeletonSettings />
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
config,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<SkeletonSettings />
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
config,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,8 +31,14 @@ export function SettingsTroubleshooting() {
|
||||
reset,
|
||||
} = useDebugBundleContext();
|
||||
|
||||
if (stage.kind === "done" || stage.kind === "error") {
|
||||
return <ResultSection stage={stage} onClose={reset} />;
|
||||
if (stage.kind === "done") {
|
||||
return (
|
||||
<DoneResult
|
||||
result={stage.result}
|
||||
uploaded={stage.uploadAttempted}
|
||||
onClose={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (stage.kind !== "idle") {
|
||||
return <ProgressSection stage={stage} onCancel={cancel} />;
|
||||
@@ -127,30 +133,6 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: ()
|
||||
);
|
||||
}
|
||||
|
||||
function ResultSection({
|
||||
stage,
|
||||
onClose,
|
||||
}: {
|
||||
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (stage.kind === "error") {
|
||||
return (
|
||||
<StatusPanel
|
||||
variant={"error"}
|
||||
title={"Bundle failed"}
|
||||
description={stage.message}
|
||||
actions={
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function DoneResult({
|
||||
result,
|
||||
uploaded,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
|
||||
setUrl(params.get("url") ?? "");
|
||||
}, []);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-nb-gray-500">
|
||||
No login URL provided.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">Continue in your browser</h1>
|
||||
<p className="max-w-sm text-sm text-nb-gray-500">
|
||||
Open the following URL to finish signing in.
|
||||
</p>
|
||||
<Button onClick={() => Connection.OpenURL(url).catch(console.error)}>
|
||||
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
|
||||
Open URL
|
||||
</Button>
|
||||
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">{url}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4 p-6">
|
||||
@@ -107,8 +120,12 @@ export default function Status() {
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center bg-nb-gray p-10">
|
||||
<NetBirdConnectToggle state={toggleState} onClick={toggleConnection} />
|
||||
<div className="flex justify-center py-6">
|
||||
<NetBirdConnectToggle
|
||||
state={toggleState}
|
||||
onClick={toggleConnection}
|
||||
disabled={unreachable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<div className="text-center">
|
||||
{done ? (
|
||||
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
|
||||
) : error ? (
|
||||
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
|
||||
) : failed ? (
|
||||
<h1 className="text-xl font-semibold text-red-500">Update failed</h1>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||
|
||||
@@ -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
|
||||
|
||||
66
client/ui/services/windows.go
Normal file
66
client/ui/services/windows.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user