diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 26a864eb2..6b085d8ca 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -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=`) — 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=`) — 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 - "` (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 diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 9714df36c..33b7fe1e7 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -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 diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md index 40964520b..494812d35 100644 --- a/client/ui/frontend/WAILS-API.md +++ b/client/ui/frontend/WAILS-API.md @@ -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` diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 8512cbb08..3afcc802b 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -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 />} /> diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx index 86db78830..2c94f937b 100644 --- a/client/ui/frontend/src/components/SquareIcon.tsx +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -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, )} > diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 0f33ba67d..1ff029714 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -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 diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx index bf421fc29..ea43640be 100644 --- a/client/ui/frontend/src/contexts/DialogContext.tsx +++ b/client/ui/frontend/src/contexts/DialogContext.tsx @@ -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. diff --git a/client/ui/frontend/src/lib/dialogs.ts b/client/ui/frontend/src/lib/dialogs.ts index 814401629..457da42e1 100644 --- a/client/ui/frontend/src/lib/dialogs.ts +++ b/client/ui/frontend/src/lib/dialogs.ts @@ -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); } diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts index 2114d8c9c..5bd2fae77 100644 --- a/client/ui/frontend/src/lib/errors.ts +++ b/client/ui/frontend/src/lib/errors.ts @@ -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; } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx new file mode 100644 index 000000000..6677ad1bd --- /dev/null +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -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> + ); +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 39dd57f87..91afc305a 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -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." } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 1e7b0c28f..2527c6ecb 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -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." } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 80f5766ff..0718d84a6 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -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." } diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 528e1d39b..ca241558b 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -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.