add custom error dialog

This commit is contained in:
Eduard Gert
2026-06-08 12:44:33 +02:00
parent 21f1142355
commit 0e4d0128b6
14 changed files with 212 additions and 68 deletions

View File

@@ -37,7 +37,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
@@ -98,7 +98,9 @@ The main window is created up front in `main.go`. Auxiliary windows are created
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
The five lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress, Error) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
@@ -123,17 +125,15 @@ The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on
## Wails Dialogs (frontend, `@wailsio/runtime`)
API surface — `Dialogs.Info` / `Warning` / `Error` / `Question` / `OpenFile` / `SaveFile`, options shape, per-OS behaviour, and the Go-side frameless-window pattern — lives in `WAILS-DIALOGS.md` (sibling). The conventions for **when** to use a native dialog vs inline UI are in the "Conventions" section below.
The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed.
## Conventions in this codebase
### Errors → native dialogs
### Errors → custom Error window
User-actionable operation failures (config save, profile switch, debug bundle, update, etc.) surface via `Dialogs.Error` with an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong". The dialog itself already says "Error" visually.
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
Confirmations use `Dialogs.Warning` with explicit `Buttons`. The promise resolves with the **button Label string**, not an index — pin the label into a variable before comparing (especially with i18n, where labels translate). Full API in `WAILS-DIALOGS.md`.
**Skip native dialogs** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no native dialog needed there.
**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
### OS notifications

View File

@@ -29,9 +29,10 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
In `app.tsx` the dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
@@ -55,6 +56,7 @@ Page-specific chrome lives next to the page, not in the layout:
- `modules/session/``SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
- `modules/auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
- `modules/error/``ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`.
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
@@ -180,9 +182,11 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
## Dialogs convention
**Always go through `src/lib/dialogs.ts`**`errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow.
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). For **confirmations inside an app window** (the polished surfaces), prefer the in-app `useConfirm()` from `contexts/DialogContext.tsx` over the native `warningDialog``const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). Reserve the native `warningDialog` (compare against the **Label string**, not an index) for confirmations raised outside a normal app window (tray-driven flows, etc.). **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time.
For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx``const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`.
## Tailwind tokens

View File

@@ -166,8 +166,12 @@ Typical enforced-update flow on the `/update` route: call `Trigger` once, then p
WindowManager.OpenSettings(): Promise<void>
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
WindowManager.CloseBrowserLogin(): Promise<void>
WindowManager.OpenError(title: string, message: string): Promise<void> // custom branded error window; both query-escaped as ?title=…&message=…
WindowManager.CloseError(): Promise<void>
```
Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised.
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
## `I18n`

View File

@@ -6,6 +6,7 @@ import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { MainPage } from "@/modules/main/MainPage.tsx";
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
@@ -41,6 +42,7 @@ Promise.all([
<Route path="session-expired" element={<SessionExpiredDialog />} />
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
<Route path="welcome" element={<WelcomeDialog />} />
<Route path="error" element={<ErrorDialog />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<MainPage />} />

View File

@@ -4,35 +4,35 @@ import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
// icon centered inside. The `tone` selects the semantic colour scheme —
// `default` keeps the neutral dark tile; info/warning/danger tint the tile,
// border and icon to match the action's severity.
export type SquareIconTone = "default" | "info" | "warning" | "danger";
// icon centered inside. The `variant` selects the semantic colour scheme — all
// variants keep the neutral dark tile + border; only the icon colour changes
// to match the action's severity.
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
const toneClass: Record<SquareIconTone, string> = {
default: "bg-nb-gray-920 border-nb-gray-900 text-white",
info: "bg-sky-950 border-sky-500 text-sky-100",
warning: "bg-netbird-950 border-netbird text-netbird",
danger: "bg-red-950 border-red-500 text-red-500",
const variantClass: Record<SquareIconVariant, string> = {
default: "text-white",
info: "text-sky-400",
warning: "text-netbird",
danger: "text-red-500",
};
type SquareIconProps = {
icon: ComponentType<LucideProps>;
iconSize?: number;
tone?: SquareIconTone;
variant?: SquareIconVariant;
className?: string;
};
export const SquareIcon = ({
icon: Icon,
iconSize = 20,
tone = "default",
variant = "default",
className,
}: SquareIconProps) => (
<div
className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center border",
toneClass[tone],
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
variantClass[variant],
className,
)}
>

View File

@@ -8,7 +8,7 @@ import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to the native warningDialog.
// in the footer. It's the in-window counterpart to a native confirm dialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a

View File

@@ -2,7 +2,7 @@ import { createContext, ReactNode, useCallback, useContext, useRef, useState } f
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of the native warningDialog promise. The
// boolean — the in-app equivalent of a native confirmation dialog. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.

View File

@@ -1,42 +1,30 @@
import { Dialogs } from "@wailsio/runtime";
import { WindowManager } from "@bindings/services";
import { isWindows } from "@/lib/platform";
// Options for errorDialog. Kept as a {Title, Message} object so the many
// existing call sites read unchanged after the switch from the native OS
// MessageBox to the custom window below.
export type ErrorDialogOptions = {
Title: string;
Message: string;
};
// Derived from the runtime rather than deep-imported: the package's exports map
// only exposes the types barrel, not "@wailsio/runtime/types/dialogs".
type MessageDialogOptions = Parameters<typeof Dialogs.Error>[0];
// On Windows a native MessageBox attached to a parent window disables that
// parent (WS_DISABLED) for the lifetime of the dialog and re-enables it on
// dismissal. When the parent is the main window — whose WindowClosing hook
// hides instead of closes (main.go) — the enable/hide sequence can race and
// leave the window unable to process its close (X) button afterwards: the user
// reports the main window can no longer be closed once an error dialog (e.g. a
// rejected login) has been shown. Detaching the dialog gives the MessageBox a
// NULL owner, so no window is ever disabled and the X keeps working.
// errorDialog surfaces a user-actionable failure. It opens the custom,
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
// more, despite the name.
//
// macOS keeps the attached (sheet-style) presentation — the bug is Windows-only
// and detaching there loses the sheet animation — so we only force Detached on
// Windows and leave any caller-supplied value untouched elsewhere.
function withDetached(options: MessageDialogOptions): MessageDialogOptions {
if (options.Detached !== undefined || !isWindows()) {
return options;
}
return { ...options, Detached: true };
}
export function errorDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Error(withDetached(options));
}
export function warningDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Warning(withDetached(options));
}
export function infoDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Info(withDetached(options));
}
export function questionDialog(options: MessageDialogOptions): Promise<string> {
return Dialogs.Question(withDetached(options));
// Why the native box is gone: on Windows a native MessageBox attached to a
// parent window disables that window (WS_DISABLED) for its lifetime, and the
// main window's WindowClosing hook hides instead of closing — the two raced
// and could leave the main window unable to process its close (X) button after
// an error was shown. The custom window has its own chrome and never touches
// another window's enabled state, so that class of bug is gone (and with it
// the old `Detached: true` Windows-only workaround, plus the warning/info/
// question wrappers that nothing called).
//
// Title and message must already be localised. Resolves as soon as the window
// is opened (it does not block until the user dismisses it), so `await`ing
// callers continue immediately after the dialog appears.
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
return WindowManager.OpenError(options.Title, options.Message);
}

View File

@@ -43,7 +43,7 @@ export const formatErrorMessage = (e: unknown): string => {
const short = typeof ce.short === "string" ? ce.short : "";
const long = typeof ce.long === "string" ? ce.long : "";
if (short && long && long !== short) {
return `${short}\n\nDetails: ${long}`;
return `${short} Details: ${long}`;
}
if (short) return short;
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 380;
// ErrorDialog is the app's error surface — a frameless, always-on-top
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
// native OS MessageBox. Title and message arrive as query params (see
// services/windowmanager.go errorDialogURL); both are caller-localised. The
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
// it's repeated as the heading here so it stays visible on macOS, where the
// hidden-inset title bar doesn't render the chrome title. The single Close
// button (and the Escape key) dismisses the window via WindowManager.CloseError
// — the Go side destroys it on close.
export default function ErrorDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const title = params.get("title") || t("window.title.error");
const message = params.get("message") || "";
const close = useCallback(() => {
WindowManager.CloseError().catch(console.error);
}, []);
// Escape closes — keyboard-accessible cancellation, matching the native
// dialog's behaviour. The primary button is autoFocused below so Enter
// also dismisses.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading className={"text-balance"}>{title}</DialogHeading>
{message && (
<DialogDescription className={"text-balance"}>
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
</DialogDescription>
)}
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={close}
>
{t("common.close")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -346,6 +346,7 @@
"window.title.sessionExpiring": "Sitzung läuft ab",
"window.title.updating": "Aktualisierung",
"window.title.welcome": "Willkommen bei NetBird",
"window.title.error": "Fehler",
"welcome.title": "Suchen Sie NetBird in der Taskleiste",
"welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.",
@@ -450,5 +451,5 @@
"error.invalid_setup_key": "Der Setup-Schlüssel fehlt oder ist ungültig.",
"error.permission_denied": "Die Anmeldung wurde vom Server abgelehnt.",
"error.daemon_unreachable": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft.",
"error.unknown": "Vorgang fehlgeschlagen. Technische Details siehe unten."
"error.unknown": "Vorgang fehlgeschlagen."
}

View File

@@ -348,6 +348,7 @@
"window.title.sessionExpiring": "Session Expiring",
"window.title.updating": "Updating",
"window.title.welcome": "Welcome to NetBird",
"window.title.error": "Error",
"welcome.title": "Look for NetBird in your tray",
"welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
@@ -452,5 +453,5 @@
"error.invalid_setup_key": "The setup key is missing or invalid.",
"error.permission_denied": "Sign-in was rejected by the server.",
"error.daemon_unreachable": "The NetBird daemon is not responding. Please check that the service is running.",
"error.unknown": "Operation failed. See details for the technical message."
"error.unknown": "Operation failed."
}

View File

@@ -346,6 +346,7 @@
"window.title.sessionExpiring": "Munkamenet lejár",
"window.title.updating": "Frissítés",
"window.title.welcome": "Üdvözli a NetBird",
"window.title.error": "Hiba",
"welcome.title": "Keresse a NetBirdöt a tálcán",
"welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.",
@@ -450,5 +451,5 @@
"error.invalid_setup_key": "A telepítési kulcs hiányzik vagy érvénytelen.",
"error.permission_denied": "A szerver elutasította a bejelentkezést.",
"error.daemon_unreachable": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás.",
"error.unknown": "A művelet meghiúsult. A technikai részleteket a Details mezőben találja."
"error.unknown": "A művelet meghiúsult."
}

View File

@@ -156,6 +156,7 @@ type WindowManager struct {
sessionAboutToExpire *application.WebviewWindow
installProgress *application.WebviewWindow
welcome *application.WebviewWindow
errorDialog *application.WebviewWindow
// hiddenForLogin remembers windows that were visible when the
// BrowserLogin popup opened. They were Hide()n to keep focus on the
// SSO flow without resorting to AlwaysOnTop, and are restored when
@@ -271,6 +272,7 @@ func (s *WindowManager) retitleAll() {
{s.sessionAboutToExpire, "window.title.sessionExpiring"},
{s.installProgress, "window.title.updating"},
{s.welcome, "window.title.welcome"},
{s.errorDialog, "window.title.error"},
}
s.mu.Unlock()
for _, p := range wins {
@@ -597,6 +599,72 @@ func (s *WindowManager) CloseWelcome() {
}
}
// OpenError shows a custom error dialog window above all other application
// windows. The window's chrome title is always the generic localised "Error";
// `title` is the error's name (e.g. a login failure passes the translated
// "Login Failed") and is rendered as the dialog heading in the body, while
// `message` is the body text below it. The caller is responsible for localising
// both. title + message are carried in the window's start URL so the page reads
// them via useSearchParams; if the window is already open it is steered to the
// new content via SetURL so a second error replaces the first instead of
// stacking another window. Singleton — destroyed on close. Created Hidden so
// the React side can auto-size to the (variable-length) message before paint.
//
// This is the in-window alternative to the native errorDialog wrapper: it
// keeps the frameless NetBird chrome and survives the Windows-MessageBox
// parent-disable race that the native path has to detach around.
func (s *WindowManager) OpenError(title, message string) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := errorDialogURL(title, message)
if s.errorDialog == nil {
s.errorDialog = s.app.Window.NewWithOptions(
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
)
s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.errorDialog = nil
s.mu.Unlock()
})
s.centerWhenReady(s.errorDialog)
return
}
s.errorDialog.SetURL(startURL)
s.errorDialog.Show()
s.errorDialog.Focus()
s.centerWhenReady(s.errorDialog)
}
// errorDialogURL builds the hash-route start URL for the error window with the
// title (rendered as the body heading) and message carried as query params.
// Both are query-escaped so newlines, ampersands, and other characters common
// in formatted daemon errors survive the round-trip into useSearchParams.
func errorDialogURL(title, message string) string {
q := url.Values{}
if title != "" {
q.Set("title", title)
}
if message != "" {
q.Set("message", message)
}
startURL := "/#/dialog/error"
if enc := q.Encode(); enc != "" {
startURL += "?" + enc
}
return startURL
}
// CloseError destroys the error dialog window if open.
func (s *WindowManager) CloseError() {
s.mu.Lock()
w := s.errorDialog
s.errorDialog = nil
s.mu.Unlock()
if w != nil {
w.Close()
}
}
// OpenMain brings the main window forward. Used by the welcome Continue
// button to hand off from onboarding to the regular UI without depending
// on the tray.