Compare commits
12 Commits
feature/se
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4f35352f | ||
|
|
85029898a5 | ||
|
|
c3aeb5be15 | ||
|
|
df61f22d96 | ||
|
|
32df29bbd4 | ||
|
|
0a458ead8b | ||
|
|
aab8274b1a | ||
|
|
d3b660afba | ||
|
|
341848b1ae | ||
|
|
414e7815e4 | ||
|
|
a7b26e3c0d | ||
|
|
42534b24c5 |
@@ -109,7 +109,7 @@ func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
w.Update(time.Time{})
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||
@@ -122,7 +122,7 @@ func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if events[0].kind != stateChange {
|
||||
@@ -139,9 +139,9 @@ func TestSameDeadlineIsNoop(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
w.Update(d)
|
||||
w.Update(d)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if len(events) != 1 {
|
||||
@@ -157,7 +157,7 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||
|
||||
// Deadline 80ms out — warning should fire after ~30ms.
|
||||
d := time.Now().Add(80 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[0].kind != stateChange {
|
||||
@@ -174,7 +174,7 @@ func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(10 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
@@ -189,12 +189,12 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Replace with a far-future deadline before the warning fires.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
second := time.Now().Add(time.Hour)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// Wait past when first's warning would have fired.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
@@ -211,14 +211,14 @@ func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
waitForEvents(t, r, 2)
|
||||
|
||||
// Simulate a successful extend: brand new deadline.
|
||||
second := time.Now().Add(60 * time.Millisecond)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||
events := waitForEvents(t, r, 4)
|
||||
@@ -236,10 +236,10 @@ func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
w.Update(time.Time{})
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -333,7 +333,7 @@ func TestCloseSilencesUpdates(t *testing.T) {
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
w.Update(time.Now().Add(time.Hour))
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
@@ -348,7 +348,7 @@ func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Expect stateChange + warning + final-warning.
|
||||
events := waitForEvents(t, r, 3)
|
||||
@@ -384,7 +384,7 @@ func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Wait for the warning publish so we know we're inside the warning
|
||||
// window, then dismiss before the final timer would fire.
|
||||
@@ -415,7 +415,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(first)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Dismiss against the first deadline.
|
||||
w.Dismiss()
|
||||
@@ -423,7 +423,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
// Replace with a fresh deadline before the first's timers complete.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(second)
|
||||
_ = w.Update(second)
|
||||
|
||||
// The second cycle must publish a final-warning (the dismiss state
|
||||
// did not carry over).
|
||||
@@ -448,7 +448,7 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
w.Dismiss()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Final warning should still publish — Dismiss only acts on the current
|
||||
// deadline, and there was none at the time of the call.
|
||||
|
||||
@@ -1048,16 +1048,16 @@ func HumaniseDuration(d time.Duration) string {
|
||||
}
|
||||
|
||||
const (
|
||||
day = 24 * time.Hour
|
||||
hour = time.Hour
|
||||
min = time.Minute
|
||||
day = 24 * time.Hour
|
||||
hour = time.Hour
|
||||
minute = time.Minute
|
||||
)
|
||||
|
||||
days := d / day
|
||||
d -= days * day
|
||||
hours := d / hour
|
||||
d -= hours * hour
|
||||
minutes := d / min
|
||||
days := int64(d / day)
|
||||
d -= time.Duration(days) * day
|
||||
hours := int64(d / hour)
|
||||
d -= time.Duration(hours) * hour
|
||||
minutes := int64(d / minute)
|
||||
|
||||
switch {
|
||||
case days > 0:
|
||||
|
||||
@@ -34,8 +34,8 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
|
||||
| `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` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/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`. `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). 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}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage`, persists, and broadcasts `netbird:preferences:changed`. |
|
||||
|
||||
@@ -91,8 +91,9 @@ The main window is created up front in `main.go`. Auxiliary windows are created
|
||||
- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
|
||||
- **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
|
||||
- **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up.
|
||||
- **InstallProgress** (`/#/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). The DEV-only "Development" tab has a "Show updating dialog" button that opens this window directly for preview.
|
||||
|
||||
All four auxiliary windows 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 auxiliaries.
|
||||
All five auxiliary windows 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 auxiliaries.
|
||||
|
||||
The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel(); window.Hide()`). The user reaches "really quit" through the tray → Quit menu entry.
|
||||
|
||||
@@ -125,7 +126,7 @@ User-actionable operation failures (config save, profile switch, debug bundle, u
|
||||
|
||||
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); dedicated screens like `/update` may show an additional inline header alongside the dialog so the screen isn't blank after dismissal.
|
||||
**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.
|
||||
|
||||
### OS notifications
|
||||
|
||||
|
||||
BIN
client/ui/assets/netbird-menu-16.png
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
client/ui/assets/netbird-menu-24.png
Normal file
|
After Width: | Height: | Size: 739 B |
BIN
client/ui/assets/netbird-menu-dot-connected-16.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
client/ui/assets/netbird-menu-dot-connected-22.png
Normal file
|
After Width: | Height: | Size: 615 B |
BIN
client/ui/assets/netbird-menu-dot-connecting-16.png
Normal file
|
After Width: | Height: | Size: 520 B |
BIN
client/ui/assets/netbird-menu-dot-connecting-22.png
Normal file
|
After Width: | Height: | Size: 637 B |
BIN
client/ui/assets/netbird-menu-dot-error-16.png
Normal file
|
After Width: | Height: | Size: 532 B |
BIN
client/ui/assets/netbird-menu-dot-error-22.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
client/ui/assets/netbird-menu-dot-idle-16.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
client/ui/assets/netbird-menu-dot-idle-22.png
Normal file
|
After Width: | Height: | Size: 602 B |
BIN
client/ui/assets/netbird-menu-dot-login-16.png
Normal file
|
After Width: | Height: | Size: 537 B |
BIN
client/ui/assets/netbird-menu-dot-login-22.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
client/ui/assets/netbird-menu-dot-offline-16.png
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
client/ui/assets/netbird-menu-dot-offline-22.png
Normal file
|
After Width: | Height: | Size: 605 B |
7
client/ui/assets/svg/netbird-menu.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(0.5 4.5)">
|
||||
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -25,13 +25,13 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
| `/` | `Main` | `AppLayout` | Main window default route |
|
||||
| `/quick` | `QuickActions` | none | Standalone — **prototype**, not currently invoked by the Go side |
|
||||
| `/browser-login` | `WaitingForBrowserDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
|
||||
| `/update` | `Update` (pages) | none | Main window during enforced-update install |
|
||||
| `/install-progress` | `InstallProgressDialog` (modules/auto-update) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). Dev "Show updating dialog" button in `SettingsDevelopment` opens it directly. |
|
||||
| `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
|
||||
| `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
|
||||
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). The `Profiles` tab (`modules/settings/SettingsProfiles.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")` — `Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/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. The remaining order is structural — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints `<UpdatingOverlay/>` so it has to be outermost in z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`).
|
||||
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/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). `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`).
|
||||
|
||||
`SettingsLayout` uses the same provider stack minus the `Header`. It also reserves a 38px `wails-draggable` strip at the top so the macOS traffic-light buttons (the window uses `MacTitleBarHiddenInset`) don't overlap content.
|
||||
|
||||
@@ -49,7 +49,9 @@ Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed
|
||||
| `netbird:event` | `SystemEvent` | `services/peers.go toastStreamLoop` | Not currently subscribed on the TS side — Status is read via `useStatus().status.events` instead. The tray (Go) consumes it for OS notifications. |
|
||||
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `modules/profile/ProfileContext` refreshes so a tray-initiated switch paints in the React UI. |
|
||||
| `netbird:update:available` | `UpdateAvailable` | `services/peers.go fanOutUpdateEvents` | Not directly subscribed on the TS side; `ClientVersionContext` derives `updateVersion` from `status.events` metadata instead. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | same | Same — drives the tray; Go side opens the `/update` route. |
|
||||
| `netbird:update:progress` | `UpdateProgress` | same | Drives the tray. UI side: `WindowManager.OpenInstallProgress` is what opens the install window; the React listener for `installing` flips lives in `ClientVersionContext`. |
|
||||
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `modules/auto-update/ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing`. |
|
||||
| `netbird:dev:overrides` | `{updateAvailable, enforced, version}` | `modules/settings/SettingsDevelopment.tsx` toggles | `modules/auto-update/ClientVersionContext` listens and overrides daemon-reported update state when the dev toggle is on. In-memory only; resets when Settings window closes. |
|
||||
| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `layouts/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
|
||||
| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()` when fired. No Go-side emitter today. |
|
||||
|
||||
@@ -67,7 +69,11 @@ State that crosses screens / windows lives in context. Each provider is mounted
|
||||
|
||||
- **`DebugBundleProvider` + `useDebugBundle`** (`modules/debug-bundle/`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
|
||||
|
||||
- **`ClientVersionContext`** (`modules/auto-update/`) — derives `updateAvailable` / `updateVersion` from `Status.events` metadata (`new_version_available` key), exposes `triggerUpdate`, mounts `<UpdateAvailableBanner/>` + `<UpdatingOverlay/>` so every screen inherits them. **Dev preview flags at the top of the file** (`FORCE_UPDATE_AVAILABLE`, `FORCE_UPDATING`, `FORCE_VERSION`, `HIDE_UPDATE_AVAILABLE`, `FORCE_ERROR`, `FORCE_ERROR_MSG`) override daemon state for UI preview. `FORCE_UPDATE_AVAILABLE = true` is currently committed — flip back to `false` before a real release. `UpdateAvailableBanner` additionally returns null in `import.meta.env.DEV`.
|
||||
- **`ClientVersionContext`** (`modules/auto-update/`) — seeds from `Update.GetState()` and subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. **Three branches**:
|
||||
1. `available && !enforced` — download-only. `UpdateVersionCard` shows "Version X is available for download" + "Download installer" → opens GitHub releases.
|
||||
2. `available && enforced && !installing` — user-driven enforced. `UpdateVersionCard` shows "Version X is available for install" + "Install now" → `triggerUpdate` opens `/install-progress` window then calls `Update.Trigger()`.
|
||||
3. `available && enforced && installing` — daemon already installing (force-install). The `installing` flip auto-opens `/install-progress` via `WindowManager.OpenInstallProgress`.
|
||||
Dev preview: `SettingsDevelopment` toggles emit `netbird:dev:overrides`, which this provider listens for and overrides `available / enforced / version`. No more module-level `FORCE_*` constants.
|
||||
|
||||
### Wide/narrow panel + no client-side persistence
|
||||
|
||||
@@ -150,7 +156,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
|
||||
|
||||
## Wails-specific quirks
|
||||
|
||||
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, the UpdatingOverlay). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, dialog wrappers like `ConfirmDialog`). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
|
||||
- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod.
|
||||
- **`Window.SetSize(w, h)`.** Called from `Header.tsx` to switch between 380-wide and 925-wide layouts. There's a one-time initial sync on mount so localStorage's `expanded` flag wins over the Go-side default of 925.
|
||||
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails).
|
||||
@@ -160,10 +166,8 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
|
||||
- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `MainRightSide` and is what the user sees today; the real-data one isn't wired into the route table.
|
||||
- **`screens/Profiles.tsx`** still imports bindings via the deep relative path. It's the example of the preferred `ProfileSwitcher.SwitchActive` flow but otherwise pre-AppLayout.
|
||||
- **`pages/Debug.tsx`** is the legacy debug-bundle screen. The polished flow is in `modules/settings/SettingsTroubleshooting.tsx` (via `useDebugBundle`). `pages/Debug.tsx` isn't currently routed.
|
||||
- **`pages/Update.tsx`** and **`screens/Update.tsx`** are two different update pages. The route table points at `pages/Update.tsx` (the production one with the 15-minute timeout, daemon-down-grace, and error-mapping). The `screens/Update.tsx` is an older simpler variant.
|
||||
- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
|
||||
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
|
||||
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
|
||||
|
||||
## Wails Go API reference
|
||||
|
||||
|
||||
3193
client/ui/frontend/pnpm-lock.yaml
generated
@@ -5,7 +5,7 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import QuickActions from "@/screens/QuickActions.tsx";
|
||||
import SessionExpiredDialog from "@/modules/authentication/SessionExpiredDialog.tsx";
|
||||
import SessionAboutToExpireDialog from "@/modules/authentication/SessionAboutToExpireDialog.tsx";
|
||||
import Update from "@/screens/Update.tsx";
|
||||
import InstallProgressDialog from "@/modules/auto-update/InstallProgressDialog.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { SettingsLayout } from "@/layouts/SettingsLayout.tsx";
|
||||
import { Main } from "@/layouts/Main.tsx";
|
||||
@@ -33,7 +33,7 @@ initI18n()
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickActions />} />
|
||||
<Route path="/browser-login" element={<WaitingForBrowserDialog />} />
|
||||
<Route path="/update" element={<Update />} />
|
||||
<Route path="/install-progress" element={<InstallProgressDialog />} />
|
||||
<Route path="/session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="/session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
<Route element={<SettingsLayout />}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NewProfileModal } from "@/components/NewProfileModal";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -40,7 +41,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -70,7 +71,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -194,7 +195,7 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} className={"text-nb-gray-200 shrink-0"} />
|
||||
<span className={"text-sm font-medium capitalize truncate max-w-[140px]"}>
|
||||
<span className={"text-sm font-medium truncate max-w-[140px]"}>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown size={14} className={"text-nb-gray-200 shrink-0"} />
|
||||
@@ -225,7 +226,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
>
|
||||
<Icon size={14} className={cn("shrink-0", showEmail && "mt-0.5")} />
|
||||
<div className="flex flex-col min-w-0 flex-1 leading-tight">
|
||||
<span className="capitalize truncate">{profile.name}</span>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
</div>
|
||||
{isActive && (
|
||||
|
||||
@@ -230,10 +230,11 @@
|
||||
"update.banner.message": "NetBird {version} ist installationsbereit.",
|
||||
"update.banner.later": "Später",
|
||||
"update.banner.installNow": "Jetzt installieren",
|
||||
"update.card.versionAvailable": "Version {version} ist verfügbar.",
|
||||
"update.card.versionAvailableDownload": "Version {version} ist zum Herunterladen verfügbar.",
|
||||
"update.card.versionAvailableInstall": "Version {version} ist zur Installation verfügbar.",
|
||||
"update.card.whatsNew": "Was ist neu?",
|
||||
"update.card.installNow": "Jetzt installieren",
|
||||
"update.card.getInstaller": "Installer holen",
|
||||
"update.card.getInstaller": "Installer herunterladen",
|
||||
"update.card.lastChecked": "Zuletzt geprüft am {date}",
|
||||
"update.card.changelog": "Änderungsprotokoll",
|
||||
"update.card.checkForUpdates": "Nach Updates suchen",
|
||||
@@ -250,6 +251,8 @@
|
||||
"update.overlay.error.unknownMessage": "unbekannter Fehler",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "die neue Version",
|
||||
"update.error.loadStateTitle": "Laden des Update-Status fehlgeschlagen",
|
||||
"update.error.triggerTitle": "Update-Start fehlgeschlagen",
|
||||
|
||||
"update.page.versionLine": "Client wird aktualisiert auf: {version}.",
|
||||
"update.page.versionLineGeneric": "Client wird aktualisiert.",
|
||||
@@ -293,5 +296,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird-Dienst läuft nicht",
|
||||
"daemon.unavailable.description": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft.",
|
||||
"daemon.unavailable.docsLink": "Dokumentation"
|
||||
"daemon.unavailable.docsLink": "Dokumentation",
|
||||
|
||||
"error.jwt_clock_skew": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut.",
|
||||
"error.jwt_expired": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error.jwt_signature_invalid": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"error.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -251,10 +251,11 @@
|
||||
"update.banner.message": "NetBird {version} is ready to install.",
|
||||
"update.banner.later": "Later",
|
||||
"update.banner.installNow": "Install now",
|
||||
"update.card.versionAvailable": "Version {version} is available.",
|
||||
"update.card.versionAvailableDownload": "Version {version} is available for download.",
|
||||
"update.card.versionAvailableInstall": "Version {version} is available for install.",
|
||||
"update.card.whatsNew": "What's new?",
|
||||
"update.card.installNow": "Install now",
|
||||
"update.card.getInstaller": "Get installer",
|
||||
"update.card.getInstaller": "Download installer",
|
||||
"update.card.lastChecked": "Last checked on {date}",
|
||||
"update.card.changelog": "Changelog",
|
||||
"update.card.checkForUpdates": "Check for updates",
|
||||
@@ -271,6 +272,8 @@
|
||||
"update.overlay.error.unknownMessage": "unknown error",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "the new version",
|
||||
"update.error.loadStateTitle": "Load Update State Failed",
|
||||
"update.error.triggerTitle": "Start Update Failed",
|
||||
|
||||
"update.page.versionLine": "Updating client to: {version}.",
|
||||
"update.page.versionLineGeneric": "Updating client.",
|
||||
@@ -314,5 +317,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird Service Is Not Running",
|
||||
"daemon.unavailable.description": "The app will reconnect automatically once the service is running.",
|
||||
"daemon.unavailable.docsLink": "Documentation"
|
||||
"daemon.unavailable.docsLink": "Documentation",
|
||||
|
||||
"error.jwt_clock_skew": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
"error.jwt_expired": "Your sign-in token has expired. Please sign in again.",
|
||||
"error.jwt_signature_invalid": "Sign-in failed: the token signature is invalid. Please contact your administrator.",
|
||||
"error.session_expired": "Your session has expired. Please sign in again.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -230,7 +230,8 @@
|
||||
"update.banner.message": "A NetBird {version} telepítésre kész.",
|
||||
"update.banner.later": "Később",
|
||||
"update.banner.installNow": "Telepítés most",
|
||||
"update.card.versionAvailable": "Elérhető a {version} verzió.",
|
||||
"update.card.versionAvailableDownload": "A {version} verzió letöltésre elérhető.",
|
||||
"update.card.versionAvailableInstall": "A {version} verzió telepítésre elérhető.",
|
||||
"update.card.whatsNew": "Mi az újdonság?",
|
||||
"update.card.installNow": "Telepítés most",
|
||||
"update.card.getInstaller": "Telepítő letöltése",
|
||||
@@ -250,6 +251,8 @@
|
||||
"update.overlay.error.unknownMessage": "ismeretlen hiba",
|
||||
"update.overlay.error.targetVersion": "v{version}",
|
||||
"update.overlay.error.targetFallback": "az új verzió",
|
||||
"update.error.loadStateTitle": "Frissítési állapot betöltése sikertelen",
|
||||
"update.error.triggerTitle": "Frissítés indítása sikertelen",
|
||||
|
||||
"update.page.versionLine": "Kliens frissítése erre: {version}.",
|
||||
"update.page.versionLineGeneric": "Kliens frissítése.",
|
||||
@@ -293,5 +296,14 @@
|
||||
|
||||
"daemon.unavailable.title": "A NetBird szolgáltatás nem fut",
|
||||
"daemon.unavailable.description": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető.",
|
||||
"daemon.unavailable.docsLink": "Dokumentáció"
|
||||
"daemon.unavailable.docsLink": "Dokumentáció",
|
||||
|
||||
"error.jwt_clock_skew": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra.",
|
||||
"error.jwt_expired": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"error.jwt_signature_invalid": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával.",
|
||||
"error.session_expired": "A munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
@@ -38,8 +39,7 @@ const NEEDS_LOGIN_STATES = new Set([
|
||||
"LoginFailed",
|
||||
]);
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
|
||||
// popup window is the only login UI; errors surface as a native
|
||||
@@ -230,6 +230,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
// See connect() above — clear via the effect, not eagerly.
|
||||
};
|
||||
|
||||
// Tracks whether the daemon has entered Connecting during the
|
||||
// current "connect" action. Lets us distinguish "still waiting for
|
||||
// the daemon to start" (Idle → Idle) from "the connect flow was
|
||||
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
|
||||
// while the UI was Connecting). Reset whenever action returns to
|
||||
// null.
|
||||
const sawConnectingRef = useRef(false);
|
||||
|
||||
// Release the action latch when the daemon settles on a terminal
|
||||
// state for the user's intent — and, in the connect → NeedsLogin
|
||||
// case, hand off to driveLogin so the user doesn't have to click
|
||||
@@ -237,6 +245,13 @@ export const ConnectionStatusSwitch = () => {
|
||||
// .finally, not here: Login's internal Down makes the daemon flap
|
||||
// through Idle, which would otherwise look like a terminal state.
|
||||
useEffect(() => {
|
||||
if (action === null) {
|
||||
sawConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (daemonState === "Connecting") {
|
||||
sawConnectingRef.current = true;
|
||||
}
|
||||
if (action === "connect") {
|
||||
if (needsLogin) {
|
||||
driveLogin();
|
||||
@@ -244,6 +259,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
}
|
||||
if (daemonState === "Connected" || unreachable) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
// Cancelled externally (e.g. tray Disconnect during our
|
||||
// Connecting): the daemon went back to Idle after we'd
|
||||
// observed Connecting. Clear the latch so the UI stops
|
||||
// showing Connecting forever.
|
||||
if (sawConnectingRef.current && daemonState === "Idle") {
|
||||
setAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
MoreVertical,
|
||||
PanelTop,
|
||||
PanelsRightBottom,
|
||||
RectangleVertical,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -51,16 +51,13 @@ export const Header = () => {
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"flex justify-center ml-3"}>
|
||||
<div className={"flex justify-center ml-4"}>
|
||||
<ProfileDropdown onManageProfiles={openManageProfiles} />
|
||||
</div>
|
||||
<div className={"flex justify-end"}>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200"}
|
||||
/>
|
||||
<IconButton icon={MoreVertical} iconClassName={"text-nb-gray-200"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="min-w-52">
|
||||
<DropdownMenuItem onClick={openSettings}>
|
||||
@@ -71,7 +68,7 @@ export const Header = () => {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<ViewModeItem
|
||||
icon={PanelTop}
|
||||
icon={RectangleVertical}
|
||||
label={t("header.menu.defaultView")}
|
||||
selected={viewMode === "default"}
|
||||
onSelect={() => selectMode("default")}
|
||||
|
||||
52
client/ui/frontend/src/lib/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Shared error formatter for native dialog bodies.
|
||||
//
|
||||
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
|
||||
// wraps daemon errors in a ClientError struct exposed to the TS side as
|
||||
// {code, short, long}. Short is already localised (Go reads the current
|
||||
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
|
||||
// Long always carries the unwrapped raw daemon message so the operator can
|
||||
// see the JWT / mgm stack when the short text is too generic.
|
||||
//
|
||||
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
|
||||
// .message holds the JSON-stringified payload and the structured object
|
||||
// lives on .cause — Object.keys(err) is empty in that case. We therefore
|
||||
// probe .cause first, then fall back to parsing .message as JSON, then
|
||||
// to plain .message text for callers that still hand us a raw Error.
|
||||
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const withCause = e as { cause?: unknown; message?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause as { short?: string; long?: string };
|
||||
}
|
||||
if (typeof withCause.message === "string") {
|
||||
const m = withCause.message.trim();
|
||||
if (m.startsWith("{") && m.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
|
||||
return parsed.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parsed as { short?: string; long?: string };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to plain-message handling
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const ce = extractClientError(e);
|
||||
if (ce) {
|
||||
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}`;
|
||||
}
|
||||
if (short) return short;
|
||||
}
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
@@ -63,7 +64,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const start = await Session.RequestExtend({});
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
@@ -80,7 +81,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("sessionAboutToExpire.extendFailedTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -4,14 +4,24 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import { Dialogs, Events } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner";
|
||||
import { UpdatingOverlay } from "@/modules/auto-update/UpdatingOverlay";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
|
||||
// (for Trigger) handled by the install window's polling-grace branch; a
|
||||
// second popup on top of those is pure noise. Every Update RPC routes
|
||||
// through the shared gRPC conn, so the Unavailable code is the marker.
|
||||
const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
};
|
||||
|
||||
type ClientVersionContextValue = {
|
||||
updateAvailable: boolean;
|
||||
@@ -20,42 +30,22 @@ type ClientVersionContextValue = {
|
||||
installing: boolean;
|
||||
triggerUpdate: () => void;
|
||||
updating: boolean;
|
||||
updateError: string | null;
|
||||
dismissUpdateError: () => void;
|
||||
};
|
||||
|
||||
// Dev toggles — flip to preview UI states without triggering real flows.
|
||||
const FORCE_UPDATE_AVAILABLE = false;
|
||||
const FORCE_UPDATING = false;
|
||||
const FORCE_ENFORCED = true;
|
||||
const FORCE_VERSION = "0.65.0";
|
||||
// Hide all "update available" UI (header trigger, settings badge, banner)
|
||||
// regardless of what the daemon reports.
|
||||
const HIDE_UPDATE_AVAILABLE = false;
|
||||
// FORCE_ERROR options:
|
||||
// null → no error (loading state)
|
||||
// "timeout" → "Update timed out" state
|
||||
// "cancel" → "Update canceled" state
|
||||
// "fail" → generic "Update failed" state (uses FORCE_ERROR_MSG)
|
||||
type ForceError = "timeout" | "cancel" | "fail" | null;
|
||||
const FORCE_ERROR = null as ForceError;
|
||||
const FORCE_ERROR_MSG = "installer exited with code 1";
|
||||
|
||||
const forcedErrorMessage = (): string | null => {
|
||||
switch (FORCE_ERROR) {
|
||||
case "timeout":
|
||||
return "update timed out after 15m";
|
||||
case "cancel":
|
||||
return "update canceled by user";
|
||||
case "fail":
|
||||
return FORCE_ERROR_MSG;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const EVENT_UPDATE_STATE = "netbird:update:state";
|
||||
|
||||
// Dev tab in Settings emits this with { updateAvailable, enforced, version }.
|
||||
// Lives only in-memory in the main window for the session — losing it when
|
||||
// Settings closes is acceptable per the dev-toggle scope (no daemon write,
|
||||
// no persistence). See SettingsDevelopment.tsx.
|
||||
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
|
||||
|
||||
type DevOverrides = {
|
||||
updateAvailable: boolean;
|
||||
enforced: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const emptyState: UpdateState = {
|
||||
available: false,
|
||||
version: "",
|
||||
@@ -76,11 +66,8 @@ export const useClientVersion = () => {
|
||||
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, setState] = useState<UpdateState>(emptyState);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [devOverride, setDevOverride] = useState<DevOverrides | null>(null);
|
||||
|
||||
// Pull the current state once on mount so a banner / overlay that
|
||||
// re-renders later in the session still has the right baseline, then
|
||||
// subscribe to the push channel for live updates.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
UpdateSvc.GetState()
|
||||
@@ -88,8 +75,12 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
if (cancelled || !s) return;
|
||||
setState(s);
|
||||
})
|
||||
.catch(() => {
|
||||
/* daemon unreachable — leave defaults */
|
||||
.catch((e) => {
|
||||
if (cancelled || isDaemonUnavailable(e)) return;
|
||||
void Dialogs.Error({
|
||||
Title: i18next.t("update.error.loadStateTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
});
|
||||
const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
|
||||
if (ev?.data) setState(ev.data);
|
||||
@@ -100,40 +91,62 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Merge the live state with dev overrides. The overrides win so designers
|
||||
// can preview any branch without involving the daemon.
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_DEV_OVERRIDES, (ev: { data: DevOverrides }) => {
|
||||
if (ev?.data) setDevOverride(ev.data);
|
||||
});
|
||||
return () => {
|
||||
off?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Dev override only kicks in when it explicitly forces updateAvailable on.
|
||||
// Otherwise daemon truth wins.
|
||||
const effective = useMemo<UpdateState>(() => {
|
||||
if (HIDE_UPDATE_AVAILABLE) return emptyState;
|
||||
if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) {
|
||||
if (devOverride && devOverride.updateAvailable) {
|
||||
return {
|
||||
available: true,
|
||||
version: FORCE_VERSION,
|
||||
enforced: FORCE_ENFORCED,
|
||||
installing: FORCE_UPDATING,
|
||||
version: devOverride.version || "0.65.0",
|
||||
enforced: devOverride.enforced,
|
||||
installing: state.installing,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}, [state]);
|
||||
}, [state, devOverride]);
|
||||
|
||||
// Force-install branch: daemon's progress_window:show flipped installing
|
||||
// to true while the UI was idle. Open the install window so the user
|
||||
// sees the progress UI without having to click anything.
|
||||
const prevInstallingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (effective.installing && !prevInstallingRef.current) {
|
||||
WindowManager.OpenInstallProgress(effective.version || "").catch(console.error);
|
||||
}
|
||||
prevInstallingRef.current = effective.installing;
|
||||
}, [effective.installing, effective.version]);
|
||||
|
||||
// Enforced user-driven branch: kick Trigger() in the background, then
|
||||
// hand off to the install window. The window owns the polling loop and
|
||||
// the final Quit() — this provider just fires the trigger.
|
||||
const triggerUpdate = useCallback(() => {
|
||||
setUpdateError(null);
|
||||
setUpdating(true);
|
||||
WindowManager.OpenInstallProgress(effective.version || "").catch(console.error);
|
||||
UpdateSvc.Trigger()
|
||||
.then((result) => {
|
||||
if (!result?.success) {
|
||||
setUpdateError(result?.errorMsg || "Update failed");
|
||||
setUpdating(false);
|
||||
}
|
||||
.catch(async (e) => {
|
||||
// The daemon may already be down (force-install branch raced
|
||||
// us). The install window's polling loop handles that case.
|
||||
// Anything else is a real failure — close the install window
|
||||
// (otherwise it spins forever on a daemon that won't ever
|
||||
// produce a result) and surface the error.
|
||||
if (isDaemonUnavailable(e)) return;
|
||||
WindowManager.CloseInstallProgress().catch(console.error);
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("update.error.triggerTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setUpdateError(String(e));
|
||||
setUpdating(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissUpdateError = useCallback(() => setUpdateError(null), []);
|
||||
|
||||
const showOverlay = updating || effective.installing || updateError || FORCE_ERROR;
|
||||
.finally(() => setUpdating(false));
|
||||
}, [effective.version]);
|
||||
|
||||
const value = useMemo<ClientVersionContextValue>(
|
||||
() => ({
|
||||
@@ -143,23 +156,13 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
installing: effective.installing,
|
||||
triggerUpdate,
|
||||
updating,
|
||||
updateError,
|
||||
dismissUpdateError,
|
||||
}),
|
||||
[effective, triggerUpdate, updating, updateError, dismissUpdateError],
|
||||
[effective, triggerUpdate, updating],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientVersionContext.Provider value={value}>
|
||||
{children}
|
||||
<UpdateAvailableBanner />
|
||||
{showOverlay && (
|
||||
<UpdatingOverlay
|
||||
version={effective.version || null}
|
||||
error={updateError ?? forcedErrorMessage()}
|
||||
onDismiss={dismissUpdateError}
|
||||
/>
|
||||
)}
|
||||
</ClientVersionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2, XCircle } from "lucide-react";
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// Sustained gRPC failure during install is taken as success — the daemon
|
||||
// gets restarted by the installer mid-flight, mirroring the legacy Fyne
|
||||
// UI's branch in client/ui/update.go.
|
||||
const DAEMON_DOWN_GRACE_MS = 5000;
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
type Phase =
|
||||
| { kind: "running" }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "canceled" }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
export default function InstallProgressDialog() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const version = params.get("version") ?? "";
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "running" });
|
||||
const phaseRef = useRef(phase);
|
||||
phaseRef.current = phase;
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const start = Date.now();
|
||||
let firstUnreachableAt: number | null = null;
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
if (phaseRef.current.kind !== "running") return;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
clearInterval(timer);
|
||||
setPhase({ kind: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
firstUnreachableAt = null;
|
||||
if (r.success) {
|
||||
clearInterval(timer);
|
||||
UpdateSvc.Quit();
|
||||
return;
|
||||
}
|
||||
if (r.errorMsg) {
|
||||
clearInterval(timer);
|
||||
setPhase(mapInstallError(r.errorMsg));
|
||||
}
|
||||
} catch {
|
||||
const now = Date.now();
|
||||
if (firstUnreachableAt === null) {
|
||||
firstUnreachableAt = now;
|
||||
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
|
||||
clearInterval(timer);
|
||||
UpdateSvc.Quit();
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isError = phase.kind !== "running";
|
||||
const errorInfo = isError ? classifyPhase(phase, version, t) : null;
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
{isError ? (
|
||||
<SquareIcon
|
||||
icon={XCircle}
|
||||
className={"mt-4 bg-red-500 [&_svg]:text-white"}
|
||||
/>
|
||||
) : (
|
||||
<SquareIcon icon={Loader2} className={"mt-4 [&_svg]:animate-spin"} />
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
{isError
|
||||
? errorInfo!.title
|
||||
: version
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{isError ? (
|
||||
<>
|
||||
{errorInfo!.description}
|
||||
{errorInfo!.message && (
|
||||
<>
|
||||
<br />
|
||||
<span className={"first-letter:uppercase"}>
|
||||
{errorInfo!.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t("update.overlay.description")
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() =>
|
||||
WindowManager.CloseInstallProgress().catch(console.error)
|
||||
}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function mapInstallError(msg: string): Phase {
|
||||
const m = msg.trim().toLowerCase();
|
||||
if (m === "") return { kind: "failed", message: "unknown update error" };
|
||||
if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) {
|
||||
return { kind: "timeout" };
|
||||
}
|
||||
if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) {
|
||||
return { kind: "canceled" };
|
||||
}
|
||||
return { kind: "failed", message: msg };
|
||||
}
|
||||
|
||||
type Variant = { title: string; description: string; message?: string };
|
||||
|
||||
function classifyPhase(
|
||||
phase: Phase,
|
||||
version: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): Variant {
|
||||
const target = version
|
||||
? t("update.overlay.error.targetVersion", { version })
|
||||
: t("update.overlay.error.targetFallback");
|
||||
switch (phase.kind) {
|
||||
case "timeout":
|
||||
return {
|
||||
title: t("update.overlay.error.timeoutTitle"),
|
||||
description: t("update.overlay.error.timeoutDescription", { target }),
|
||||
};
|
||||
case "canceled":
|
||||
return {
|
||||
title: t("update.overlay.error.canceledTitle"),
|
||||
description: t("update.overlay.error.canceledDescription", { target }),
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
title: t("update.overlay.error.failTitle"),
|
||||
description: t("update.overlay.error.failDescription", { target }),
|
||||
message: phase.message || t("update.overlay.error.unknownMessage"),
|
||||
};
|
||||
default:
|
||||
return { title: "", description: "" };
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,13 @@ export function UpdateVersionCard() {
|
||||
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
|
||||
|
||||
if (updateVersion) {
|
||||
const titleKey = enforced
|
||||
? "update.card.versionAvailableInstall"
|
||||
: "update.card.versionAvailableDownload";
|
||||
return (
|
||||
<Card>
|
||||
<div>
|
||||
<Title>{t("update.card.versionAvailable", { version: updateVersion })}</Title>
|
||||
<Title>{t(titleKey, { version: updateVersion })}</Title>
|
||||
<Link
|
||||
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
|
||||
>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
type Props = {
|
||||
version: string | null;
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
type Variant = {
|
||||
title: string;
|
||||
description: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function classifyError(
|
||||
msg: string,
|
||||
version: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): Variant {
|
||||
const lower = msg.toLowerCase();
|
||||
const target = version
|
||||
? t("update.overlay.error.targetVersion", { version })
|
||||
: t("update.overlay.error.targetFallback");
|
||||
if (lower.includes("timeout") || lower.includes("timed out")) {
|
||||
return {
|
||||
title: t("update.overlay.error.timeoutTitle"),
|
||||
description: t("update.overlay.error.timeoutDescription", { target }),
|
||||
};
|
||||
}
|
||||
if (lower.includes("cancel")) {
|
||||
return {
|
||||
title: t("update.overlay.error.canceledTitle"),
|
||||
description: t("update.overlay.error.canceledDescription", { target }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("update.overlay.error.failTitle"),
|
||||
description: t("update.overlay.error.failDescription", { target }),
|
||||
message: msg || t("update.overlay.error.unknownMessage"),
|
||||
};
|
||||
}
|
||||
|
||||
export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isError = Boolean(error);
|
||||
const errorInfo = error ? classifyError(error, version, t) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950/85 backdrop-blur-sm cursor-default select-none wails-draggable"
|
||||
}
|
||||
onPointerDown={(e) => {
|
||||
if (isError) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isError) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
|
||||
{isError ? (
|
||||
<div
|
||||
className={"h-9 w-9 rounded-md flex items-center justify-center bg-red-500"}
|
||||
>
|
||||
<XCircle className={"text-white"} size={18} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={"h-9 w-9 rounded-md flex items-center justify-center bg-nb-gray-100"}
|
||||
>
|
||||
<Loader2 className={"animate-spin text-nb-gray-950"} size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<p className={"text-base font-medium text-nb-gray-50"}>
|
||||
{isError
|
||||
? errorInfo!.title
|
||||
: version
|
||||
? t("update.overlay.updatingVersion", { version })
|
||||
: t("update.overlay.updating")}
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-300"}>
|
||||
{isError ? (
|
||||
<>
|
||||
{errorInfo!.description}
|
||||
{errorInfo!.message && (
|
||||
<>
|
||||
<br />
|
||||
<span className={"first-letter:uppercase"}>
|
||||
{errorInfo!.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t("update.overlay.description")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"secondary"} size={"xs"} onClick={onDismiss}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -158,7 +159,7 @@ export const useDebugBundle = () => {
|
||||
setStage({ kind: "idle" });
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
@@ -66,7 +67,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("profile.error.loadTitle"),
|
||||
Message: msg,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
@@ -91,7 +92,7 @@ export function LanguagePicker() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -39,6 +39,7 @@ export const Settings = () => {
|
||||
<SettingsNavigationTriggers />
|
||||
<MainRightSide>
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
|
||||
@@ -13,9 +13,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
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);
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
|
||||
@@ -1,30 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
// Cross-window dev override: ClientVersionContext in the main window
|
||||
// listens for this and replaces daemon-reported update state with the
|
||||
// toggle values. Resets when the Settings window closes (no persistence
|
||||
// by design).
|
||||
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
|
||||
const PREVIEW_VERSION = "0.65.0";
|
||||
|
||||
export function SettingsDevelopment() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [enforced, setEnforced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void Events.Emit(EVENT_DEV_OVERRIDES, {
|
||||
updateAvailable,
|
||||
enforced,
|
||||
version: PREVIEW_VERSION,
|
||||
});
|
||||
}, [updateAvailable, enforced]);
|
||||
|
||||
return (
|
||||
<SectionGroup title={"Session windows"}>
|
||||
<div className={"flex flex-col gap-2 items-start"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionExpired().catch(console.error)
|
||||
<>
|
||||
<SectionGroup title={"Auto-update"}>
|
||||
<FancyToggleSwitch
|
||||
value={updateAvailable}
|
||||
onChange={setUpdateAvailable}
|
||||
label={"Is update available"}
|
||||
helpText={
|
||||
"Force the UI to think a new version is available. Reflects in the About card and the header badge."
|
||||
}
|
||||
>
|
||||
Open “Session expired”
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionAboutToExpire(336).catch(
|
||||
console.error,
|
||||
)
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={enforced}
|
||||
onChange={setEnforced}
|
||||
label={"Auto update enabled"}
|
||||
helpText={
|
||||
"Force the UI to think management has auto-update enabled. Switches the About card to “Install now”."
|
||||
}
|
||||
>
|
||||
Open “About to expire” (5:36)
|
||||
</Button>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
/>
|
||||
<div className={"flex flex-col gap-2 items-start pt-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenInstallProgress(PREVIEW_VERSION).catch(
|
||||
console.error,
|
||||
)
|
||||
}
|
||||
>
|
||||
Show updating dialog
|
||||
</Button>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Session windows"}>
|
||||
<div className={"flex flex-col gap-2 items-start"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionExpired().catch(console.error)
|
||||
}
|
||||
>
|
||||
Open “Session expired”
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionAboutToExpire(336).catch(
|
||||
console.error,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open “About to expire” (5:36)
|
||||
</Button>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { LogOut, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/Button";
|
||||
@@ -13,6 +13,7 @@ import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
@@ -45,7 +46,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -90,7 +91,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -198,7 +199,7 @@ const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowPro
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
|
||||
<div className={"flex items-center gap-2 min-w-0"}>
|
||||
<span className={"truncate font-medium text-nb-gray-100 capitalize"}>
|
||||
<span className={"truncate font-medium text-nb-gray-100"}>
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
|
||||
@@ -251,7 +252,7 @@ const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowAct
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.deregister")}
|
||||
icon={LogOut}
|
||||
icon={CircleMinus}
|
||||
onClick={onDeregister}
|
||||
hidden={!canDeregister}
|
||||
/>
|
||||
@@ -268,7 +269,7 @@ const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowAct
|
||||
|
||||
type ActionIconButtonProps = {
|
||||
label: string;
|
||||
icon: typeof LogOut;
|
||||
icon: typeof CircleMinus;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
/** When true the button still occupies space (preserves row layout)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
// How long the daemon is allowed to be unreachable before we treat it as
|
||||
// "daemon went down for the upgrade, treat as success and quit". Mirrors
|
||||
// the legacy Fyne UI's branch in client/ui/update.go where a connection
|
||||
// failure during polling is taken as the success signal.
|
||||
const DAEMON_DOWN_GRACE_MS = 5000;
|
||||
|
||||
type Phase =
|
||||
| { kind: "running"; dots: number }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "canceled" }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
export default function Update() {
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "running", dots: 1 });
|
||||
const phaseRef = useRef(phase);
|
||||
phaseRef.current = phase;
|
||||
|
||||
const version = new URLSearchParams(
|
||||
window.location.hash.split("?")[1] ?? "",
|
||||
).get("version");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const start = Date.now();
|
||||
let firstUnreachableAt: number | null = null;
|
||||
|
||||
UpdateSvc.Trigger().catch(() => {
|
||||
// The daemon may already be down (installer launched, daemon shutting
|
||||
// down). Don't treat as failure here; the poll loop's daemon-down
|
||||
// detection handles it.
|
||||
});
|
||||
|
||||
const dotTimer = setInterval(() => {
|
||||
if (cancelled) return;
|
||||
setPhase((p) =>
|
||||
p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p,
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
const pollTimer = setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
if (phaseRef.current.kind !== "running") return;
|
||||
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
setPhase({ kind: "timeout" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
firstUnreachableAt = null;
|
||||
if (r.success) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
UpdateSvc.Quit();
|
||||
return;
|
||||
}
|
||||
if (r.errorMsg) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
setPhase(mapInstallError(r.errorMsg));
|
||||
}
|
||||
} catch {
|
||||
// RPC failed. The daemon often goes away mid-upgrade — treat a
|
||||
// sustained outage as success and quit, matching the legacy UI.
|
||||
const now = Date.now();
|
||||
if (firstUnreachableAt === null) {
|
||||
firstUnreachableAt = now;
|
||||
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(dotTimer);
|
||||
UpdateSvc.Quit();
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(dotTimer);
|
||||
clearInterval(pollTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const versionLine = version
|
||||
? `Updating client to: ${version}.`
|
||||
: "Updating client.";
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="whitespace-pre-line text-sm text-nb-gray-700 dark:text-nb-gray-200">
|
||||
{`Your client version is older than the auto-update version set in Management.\n${versionLine}`}
|
||||
</p>
|
||||
<p className="text-base font-medium">{statusText(phase)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(p: Phase): string {
|
||||
switch (p.kind) {
|
||||
case "running":
|
||||
return "Updating" + ".".repeat(p.dots);
|
||||
case "timeout":
|
||||
return "Update timed out. Please try again.";
|
||||
case "canceled":
|
||||
return "Update canceled.";
|
||||
case "failed":
|
||||
return "Update failed: " + p.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors mapInstallError in client/ui/update.go. The daemon's installer
|
||||
// surfaces error strings rather than typed errors, so the UI sniffs the
|
||||
// message to decide whether to show the timeout/canceled wording.
|
||||
function mapInstallError(msg: string): Phase {
|
||||
const m = msg.trim().toLowerCase();
|
||||
if (m === "") {
|
||||
return { kind: "failed", message: "unknown update error" };
|
||||
}
|
||||
if (m.includes("deadline exceeded") || m.includes("timeout")) {
|
||||
return { kind: "timeout" };
|
||||
}
|
||||
if (m.includes("canceled") || m.includes("cancelled")) {
|
||||
return { kind: "canceled" };
|
||||
}
|
||||
return { kind: "failed", message: msg };
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
const showError = (message: string) =>
|
||||
Dialogs.Error({ Title: i18next.t("update.page.failedTitle"), Message: message });
|
||||
|
||||
export default function Update() {
|
||||
const { t } = useTranslation();
|
||||
const [done, setDone] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
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) {
|
||||
clearInterval(timer);
|
||||
setFailed(true);
|
||||
void showError(i18next.t("update.page.timeoutMessage"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
if (r.success) {
|
||||
setDone(true);
|
||||
clearInterval(timer);
|
||||
} else if (r.errorMsg) {
|
||||
clearInterval(timer);
|
||||
setFailed(true);
|
||||
void showError(r.errorMsg);
|
||||
}
|
||||
} catch {
|
||||
// installer not finished yet
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
{done ? (
|
||||
<h1 className="text-xl font-semibold text-green-500">
|
||||
{t("update.page.complete")}
|
||||
</h1>
|
||||
) : failed ? (
|
||||
<h1 className="text-xl font-semibold text-red-500">
|
||||
{t("update.page.failed")}
|
||||
</h1>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold">{t("update.page.updating")}</h1>
|
||||
<p className="mt-1 text-sm text-nb-gray-500">
|
||||
{t("update.page.dontClose")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,24 +59,9 @@ var iconUpdateDisconnectedMacOS []byte
|
||||
//go:embed assets/netbird.png
|
||||
var iconWindow []byte
|
||||
|
||||
// Small colored dots shown next to the status menu entry. Rendered as
|
||||
// regular NSImage/HBITMAP/GTK menu-item icons (not template), so the
|
||||
// colours stay intact on every platform.
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connected.png
|
||||
var iconMenuDotConnected []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connecting.png
|
||||
var iconMenuDotConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-login.png
|
||||
var iconMenuDotLogin []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-error.png
|
||||
var iconMenuDotError []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-idle.png
|
||||
var iconMenuDotIdle []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-offline.png
|
||||
var iconMenuDotOffline []byte
|
||||
// Per-platform menu-row icons (status dots + NetBird brand mark) live in
|
||||
// icons_menu_windows.go and icons_menu_other.go. Windows installs them
|
||||
// into the Win32 check-mark slot, which expects SM_CXMENUCHECK-sized
|
||||
// bitmaps (~16x16 at 100% DPI) — anything bigger gets cropped, anything
|
||||
// smaller leaves blank space — so Windows ships its own 16x16 set
|
||||
// while macOS/Linux keep the larger 24x24 assets that fit their menus.
|
||||
|
||||
41
client/ui/icons_menu_darwin.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// 22x22 status dot icons used on macOS. Apple's HIG recommends an
|
||||
// 18–22 px glyph for NSMenuItem leading images; 22 matches the visual
|
||||
// weight of the surrounding row text. Windows ships a 16x16 variant
|
||||
// (Win32 SM_CXMENUCHECK slot) and Linux a 24x24 variant (GTK menu row
|
||||
// supports the larger range) — see the sibling icons_menu_*.go files.
|
||||
//
|
||||
// iconMenuNetbird is intentionally empty on macOS. NSMenuItem.setImage
|
||||
// stretches the row height to the leading image's pixel size, which
|
||||
// makes the About row taller than the unadorned rows above and below
|
||||
// it regardless of the PNG size we ship. The brand mark is rendered
|
||||
// only on Windows and Linux (see those platforms' icons_menu_*.go
|
||||
// files); on macOS the About row stays text-only — the tray icon
|
||||
// itself already supplies the brand presence.
|
||||
//
|
||||
// Status dots are downscaled from the 24x24 originals with ImageMagick.
|
||||
|
||||
var iconMenuNetbird []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connected-22.png
|
||||
var iconMenuDotConnected []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connecting-22.png
|
||||
var iconMenuDotConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-login-22.png
|
||||
var iconMenuDotLogin []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-error-22.png
|
||||
var iconMenuDotError []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-idle-22.png
|
||||
var iconMenuDotIdle []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-offline-22.png
|
||||
var iconMenuDotOffline []byte
|
||||
40
client/ui/icons_menu_linux.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// 24x24 menu-row icons used on Linux. GTK4 menu rows accept icons in the
|
||||
// 22–48 px range with no automatic downscaling at this size; 24 reads
|
||||
// cleanly next to the row text across the GNOME / KDE / minimal-WM
|
||||
// flavours we ship to. Windows ships a 16x16 variant (Win32
|
||||
// SM_CXMENUCHECK slot) and macOS a 22x22 variant — see the sibling
|
||||
// icons_menu_*.go files.
|
||||
//
|
||||
// Regenerate the brand mark from assets/svg/netbird-menu.svg (vector
|
||||
// source — re-rendering keeps the strokes crisp at every target size):
|
||||
// inkscape assets/svg/netbird-menu.svg -o netbird-menu-24.png -w 24 -h 24 \
|
||||
// --export-background-opacity=0
|
||||
// Status dots are the canonical 24x24 originals used everywhere else
|
||||
// in the legacy Fyne tray.
|
||||
|
||||
//go:embed assets/netbird-menu-24.png
|
||||
var iconMenuNetbird []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connected.png
|
||||
var iconMenuDotConnected []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connecting.png
|
||||
var iconMenuDotConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-login.png
|
||||
var iconMenuDotLogin []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-error.png
|
||||
var iconMenuDotError []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-idle.png
|
||||
var iconMenuDotIdle []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-offline.png
|
||||
var iconMenuDotOffline []byte
|
||||
42
client/ui/icons_menu_windows.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// 16x16 menu-row icons used on Windows. The Win32 SetMenuItemBitmaps API
|
||||
// paints the HBITMAP into the check-mark slot, sized to SM_CXMENUCHECK /
|
||||
// SM_CYMENUCHECK (typically 16x16 at 100% DPI). Larger bitmaps overflow
|
||||
// the row visually, so Windows ships its own scaled set instead of the
|
||||
// 24x24 assets used on macOS/Linux. Regenerate the brand mark from
|
||||
// assets/svg/netbird-menu.svg (vector source — re-rendering keeps the
|
||||
// strokes crisp at every target size):
|
||||
// inkscape assets/svg/netbird-menu.svg -o netbird-menu-16.png -w 16 -h 16 \
|
||||
// --export-background-opacity=0
|
||||
// The status dots are downscaled from the 24x24 originals with
|
||||
// ImageMagick — simple solid-fill circles survive the bicubic resize
|
||||
// without visible quality loss:
|
||||
// magick netbird-menu-dot-<state>.png -resize 16x16 \
|
||||
// -background none -gravity center -extent 16x16 \
|
||||
// netbird-menu-dot-<state>-16.png
|
||||
|
||||
//go:embed assets/netbird-menu-16.png
|
||||
var iconMenuNetbird []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connected-16.png
|
||||
var iconMenuDotConnected []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-connecting-16.png
|
||||
var iconMenuDotConnecting []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-login-16.png
|
||||
var iconMenuDotLogin []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-error-16.png
|
||||
var iconMenuDotError []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-idle-16.png
|
||||
var iconMenuDotIdle []byte
|
||||
|
||||
//go:embed assets/netbird-menu-dot-offline-16.png
|
||||
var iconMenuDotOffline []byte
|
||||
@@ -123,7 +123,6 @@ func main() {
|
||||
},
|
||||
})
|
||||
|
||||
connection := services.NewConnection(conn)
|
||||
settings := services.NewSettings(conn)
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State. Peers feeds the daemon
|
||||
@@ -133,7 +132,6 @@ func main() {
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
peers := services.NewPeers(conn, app.Event, updaterHolder)
|
||||
notifier := notifications.New()
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
// localesFS reroots the embedded tree at the locales directory itself
|
||||
// so the bundle sees _index.json and <lang>/common.json at the top
|
||||
@@ -156,6 +154,11 @@ func main() {
|
||||
}
|
||||
localizer := NewLocalizer(bundle, prefStore)
|
||||
|
||||
// Connection lives after bundle + prefStore so it can localise daemon
|
||||
// errors (services.NewConnection takes both as dependencies).
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
app.RegisterService(application.NewService(connection))
|
||||
// authsession.Session owns the full extend + dismiss surface; the tray
|
||||
// drives the "Extend now" action from the T-10 OS notification through
|
||||
@@ -189,6 +192,9 @@ func main() {
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
Linux: application.LinuxWindow{
|
||||
Icon: iconWindow,
|
||||
},
|
||||
|
||||
@@ -4,15 +4,143 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// ErrorTranslator is the subset of i18n.Bundle Connection needs to localise
|
||||
// daemon errors. Defined as an interface so tests can stub it; the runtime
|
||||
// implementation is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
}
|
||||
|
||||
// LanguagePreference is the subset of preferences.Store Connection needs
|
||||
// to discover the current UI language at error-classification time. The
|
||||
// runtime implementation is *preferences.Store.
|
||||
type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend.
|
||||
//
|
||||
// The daemon hands us gRPC errors whose Message is a stack of wrapped strings
|
||||
// from the management server and the underlying JWT library, for example:
|
||||
//
|
||||
// "invalid jwt token, err: token could not be parsed: token has invalid
|
||||
// claims: token used before issued"
|
||||
//
|
||||
// Showing that raw message in a native dialog is unreadable, so we map the
|
||||
// substrings we recognise to a {code, short, long} triple. The frontend
|
||||
// translates Code through i18n (preferred); Short is an English fallback so
|
||||
// the dialog still reads cleanly if a code is missing from the locale; Long
|
||||
// always carries the unwrapped daemon message for the operator.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
// Error returns the user-facing short message so plain Go callers and the
|
||||
// Wails default error path still get a readable string.
|
||||
func (e *ClientError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Short
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the full {code, short, long} triple so the Wails
|
||||
// binding emits a structured object instead of the default "error: ..."
|
||||
// string. The TS layer accesses these fields via try/catch.
|
||||
func (e *ClientError) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type alias ClientError
|
||||
return json.Marshal((*alias)(e))
|
||||
}
|
||||
|
||||
// classifyDaemonError turns a raw gRPC error from the daemon into a
|
||||
// ClientError with a stable code and a short localised summary. The Long
|
||||
// field always carries the unwrapped daemon message so the operator can
|
||||
// inspect the root cause when the short text is too generic. Short is
|
||||
// looked up via i18n under "error.<code>": i18n.Bundle.Translate already
|
||||
// handles current-language → English → key passthrough, so any missing
|
||||
// locale entry surfaces as a visible "error.<code>" string in the dialog —
|
||||
// a deliberate fail-loud signal that the bundle needs updating.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if st, ok := gstatus.FromError(err); ok {
|
||||
msg = st.Message()
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
switch {
|
||||
case strings.Contains(lower, "token used before issued"),
|
||||
strings.Contains(lower, "token is not valid yet"):
|
||||
code = "jwt_clock_skew"
|
||||
case strings.Contains(lower, "token is expired"),
|
||||
strings.Contains(lower, "token has expired"):
|
||||
code = "jwt_expired"
|
||||
case strings.Contains(lower, "token signature is invalid"):
|
||||
code = "jwt_signature_invalid"
|
||||
case strings.Contains(lower, "peer login has expired"):
|
||||
code = "session_expired"
|
||||
case strings.Contains(lower, "invalid setup-key"),
|
||||
strings.Contains(lower, "invalid setup key"):
|
||||
code = "invalid_setup_key"
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
code = "permission_denied"
|
||||
case strings.Contains(lower, "no connection could be made"),
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: s.translateShort(code),
|
||||
Long: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code. The i18n
|
||||
// Bundle's own Translate already falls back current-language → English →
|
||||
// key passthrough, so callers either see the localised string or the bare
|
||||
// "error.<code>" key (which makes the missing translation obvious). If
|
||||
// the translator is nil — e.g. a Connection constructed in a unit test —
|
||||
// we return the key for the same reason.
|
||||
func (s *Connection) translateShort(code string) string {
|
||||
key := "error." + code
|
||||
if s.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if s.prefs != nil {
|
||||
if pref := s.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return s.translator.Translate(lang, key)
|
||||
}
|
||||
|
||||
// LoginParams carries the fields the UI sets when starting a login.
|
||||
type LoginParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
@@ -52,11 +180,17 @@ type LogoutParams struct {
|
||||
|
||||
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
type Connection struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
}
|
||||
|
||||
func NewConnection(conn DaemonConn) *Connection {
|
||||
return &Connection{conn: conn}
|
||||
// NewConnection wires Connection with its translation dependencies. Either
|
||||
// translator or prefs may be nil; in that case classifyDaemonError falls
|
||||
// back to the English Short text baked into the error map. main.go always
|
||||
// supplies both at startup.
|
||||
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
||||
return &Connection{conn: conn, translator: translator, prefs: prefs}
|
||||
}
|
||||
|
||||
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
||||
@@ -117,7 +251,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
return LoginResult{}, s.classifyDaemonError(err)
|
||||
}
|
||||
return LoginResult{
|
||||
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
||||
@@ -137,7 +271,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
@@ -155,8 +289,10 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Up(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Up(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
@@ -164,8 +300,10 @@ func (s *Connection) Down(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.Down(ctx, &proto.DownRequest{})
|
||||
return err
|
||||
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenURL launches the user's preferred browser to display url. Mirrors the
|
||||
@@ -201,6 +339,8 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Logout(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type WindowManager struct {
|
||||
browserLogin *application.WebviewWindow
|
||||
sessionExpired *application.WebviewWindow
|
||||
sessionAboutToExpire *application.WebviewWindow
|
||||
installProgress *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
|
||||
@@ -81,6 +82,9 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
})
|
||||
s.settings.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -143,6 +147,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
})
|
||||
bl := s.browserLogin
|
||||
// User-initiated close (red X) means cancel. Emit the event so
|
||||
@@ -249,6 +256,9 @@ func (s *WindowManager) OpenSessionExpired() {
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
})
|
||||
s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -301,6 +311,9 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
})
|
||||
s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -324,3 +337,70 @@ func (s *WindowManager) CloseSessionAboutToExpire() {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInstallProgress shows the install-progress window above all other
|
||||
// application windows for the duration of the auto-update install. The
|
||||
// daemon is unreliable mid-install (it gets restarted by the installer),
|
||||
// so this window owns its own polling loop against Update.GetInstallerResult
|
||||
// and treats a sustained gRPC failure as success.
|
||||
//
|
||||
// All other visible windows are hidden while the install runs — the ticket
|
||||
// requires that the user can't reach other menus during install — and are
|
||||
// restored when the window closes (cancel, error dismissal, success-quit
|
||||
// race). Singleton, destroyed on close. Created Hidden so the React side
|
||||
// can auto-size before paint.
|
||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := "/#/install-progress"
|
||||
if version != "" {
|
||||
startURL = "/#/install-progress?version=" + url.QueryEscape(version)
|
||||
}
|
||||
if s.installProgress == nil {
|
||||
s.hideOtherWindowsLocked("install-progress")
|
||||
s.installProgress = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "install-progress",
|
||||
Title: "NetBird",
|
||||
Width: 360,
|
||||
Height: 320,
|
||||
DisableResize: true,
|
||||
AlwaysOnTop: true,
|
||||
Hidden: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||
URL: startURL,
|
||||
Mac: application.MacWindow{
|
||||
InvisibleTitleBarHeight: 38,
|
||||
Backdrop: application.MacBackdropTranslucent,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
Theme: application.Dark,
|
||||
},
|
||||
})
|
||||
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.installProgress = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.installProgress.SetURL(startURL)
|
||||
s.installProgress.Show()
|
||||
s.installProgress.Focus()
|
||||
}
|
||||
|
||||
// CloseInstallProgress destroys the install-progress window if open.
|
||||
func (s *WindowManager) CloseInstallProgress() {
|
||||
s.mu.Lock()
|
||||
w := s.installProgress
|
||||
s.installProgress = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func (t *Tray) reapplyMenuState() {
|
||||
|
||||
if t.statusItem != nil && lastStatus != "" {
|
||||
t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus))
|
||||
t.statusItem.SetEnabled(false)
|
||||
t.statusItem.SetEnabled(statusRowEnabled())
|
||||
t.applyStatusIndicator(lastStatus)
|
||||
}
|
||||
if t.sessionExpiresItem != nil {
|
||||
@@ -314,13 +314,18 @@ func (t *Tray) ShowWindow() {
|
||||
func (t *Tray) buildMenu() *application.Menu {
|
||||
menu := application.NewMenu()
|
||||
|
||||
// statusItem shows the daemon's current status. Disabled (and no
|
||||
// OnClick handler) so clicks are no-ops — the row is informational
|
||||
// only. The Connect entry below drives every actionable transition,
|
||||
// including the SSO re-auth flow for NeedsLogin/SessionExpired
|
||||
// (the daemon's Up RPC returns NeedsSSOLogin when applicable).
|
||||
// statusItem shows the daemon's current status. Informational row
|
||||
// with no OnClick handler — clicks are no-ops. Whether the row is
|
||||
// kept enabled is platform-dependent (see statusRowEnabled): on
|
||||
// Windows the disabled-state mask would desaturate the coloured
|
||||
// status dot painted into the check-mark slot, so the row stays
|
||||
// enabled there; macOS/Linux disable it so the greyed-out label
|
||||
// signals that it is not clickable. The Connect entry below drives
|
||||
// every actionable transition, including the SSO re-auth flow for
|
||||
// NeedsLogin/SessionExpired (the daemon's Up RPC returns
|
||||
// NeedsSSOLogin when applicable).
|
||||
t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")).
|
||||
SetEnabled(false).
|
||||
SetEnabled(statusRowEnabled()).
|
||||
SetBitmap(iconMenuDotIdle)
|
||||
|
||||
// sessionExpiresItem sits directly below the status row so the
|
||||
@@ -376,7 +381,15 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
about := menu.AddSubmenu(t.loc.T("tray.menu.about"))
|
||||
aboutLabel := t.loc.T("tray.menu.about")
|
||||
about := menu.AddSubmenu(aboutLabel)
|
||||
// iconMenuNetbird is empty on macOS — NSMenuItem.setImage stretches
|
||||
// the row to the leading image's pixel size, and the result looks
|
||||
// out of place next to the unadorned rows above and below. Skip the
|
||||
// brand mark there and keep the row text-only.
|
||||
if aboutItem := menu.FindByLabel(aboutLabel); aboutItem != nil && len(iconMenuNetbird) > 0 {
|
||||
aboutItem.SetBitmap(iconMenuNetbird)
|
||||
}
|
||||
about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) {
|
||||
_ = t.app.Browser.OpenURL(urlGitHubRepo)
|
||||
})
|
||||
@@ -621,11 +634,14 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
||||
connecting := strings.EqualFold(st.Status, services.StatusConnecting)
|
||||
if t.statusItem != nil {
|
||||
// Label-only: kept disabled (informational row). Swap the
|
||||
// displayed text so the user sees a familiar phrase instead
|
||||
// of the raw daemon enum.
|
||||
// Label-only: row is informational (no OnClick). Enablement
|
||||
// is platform-dependent via statusRowEnabled — Windows
|
||||
// keeps it enabled so the Win32 disabled-state mask does
|
||||
// not desaturate the coloured dot; macOS/Linux disable it.
|
||||
// Swap the displayed text so the user sees a familiar
|
||||
// phrase instead of the raw daemon enum.
|
||||
t.statusItem.SetLabel(t.loc.StatusLabel(st.Status))
|
||||
t.statusItem.SetEnabled(false)
|
||||
t.statusItem.SetEnabled(statusRowEnabled())
|
||||
t.applyStatusIndicator(st.Status)
|
||||
}
|
||||
if t.upItem != nil {
|
||||
|
||||
12
client/ui/tray_status_enabled_other.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
// statusRowEnabled reports whether the informational status row at the
|
||||
// top of the tray menu should stay enabled. False on macOS and Linux:
|
||||
// both platforms paint disabled menu rows at slightly reduced opacity
|
||||
// without desaturating the leading bitmap, so the coloured status dot
|
||||
// stays visible while the greyed-out label still signals to the user
|
||||
// that the row is informational and not clickable. Windows opts in via
|
||||
// the sibling tray_status_enabled_windows.go file.
|
||||
func statusRowEnabled() bool { return false }
|
||||
13
client/ui/tray_status_enabled_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// statusRowEnabled reports whether the informational status row at the
|
||||
// top of the tray menu should stay enabled. Always true on Windows:
|
||||
// the Win32 disabled-state mask desaturates both the row text and the
|
||||
// HBITMAP painted into the check-mark slot, so a disabled row would
|
||||
// render the coloured status dot in greyscale and defeat the indicator.
|
||||
// macOS/Linux disable the row (see tray_status_enabled_other.go) because
|
||||
// neither platform applies that desaturation and the visual cue that
|
||||
// the row is informational reads better.
|
||||
func statusRowEnabled() bool { return true }
|
||||
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: x11 gtk+-3.0 cairo cairo-xlib
|
||||
#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib
|
||||
#cgo LDFLAGS: -lX11
|
||||
#include "xembed_tray_linux.h"
|
||||
#include <X11/Xlib.h>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cairo/cairo-xlib.h>
|
||||
#include <cairo/cairo.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/x11/gdkx.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -241,7 +242,7 @@ int xembed_poll_event(Display *dpy, Window icon_win,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- GTK3 popup window menu support --- */
|
||||
/* --- GTK4 popup window menu support --- */
|
||||
|
||||
/* Implemented in Go via //export */
|
||||
extern void goMenuItemClicked(int id);
|
||||
@@ -274,18 +275,19 @@ static void free_popup_data(popup_data *pd) {
|
||||
free(pd);
|
||||
}
|
||||
|
||||
|
||||
/* Close every popup window — top-level plus any open submenus.
|
||||
Called when the user clicks an actionable item or focus leaves the
|
||||
top-level window. */
|
||||
menu tree. */
|
||||
static void close_all_popups(void) {
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
gtk_widget_destroy(GTK_WIDGET(l->data));
|
||||
gtk_window_destroy(GTK_WINDOW(l->data));
|
||||
}
|
||||
g_list_free(submenu_popups);
|
||||
submenu_popups = NULL;
|
||||
|
||||
if (popup_win) {
|
||||
gtk_widget_hide(popup_win);
|
||||
gtk_widget_set_visible(popup_win, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,24 +298,26 @@ static void on_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
|
||||
static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
int id = GPOINTER_TO_INT(user_data);
|
||||
close_all_popups();
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
/* When any popup loses focus we want to close the entire popup tree —
|
||||
unless focus moved to another window we own (e.g. opening a submenu).
|
||||
focus-out fires before the corresponding focus-in on the new window,
|
||||
so we defer the check to an idle callback: by then any sibling popup
|
||||
has had a chance to grab focus. If none of our windows still has
|
||||
toplevel focus, the user clicked outside the menu tree → tear down. */
|
||||
/* The popup is a regular WM-managed window (not override-redirect),
|
||||
so the WM hands keyboard focus to it on map. When focus moves
|
||||
elsewhere — the user clicked somewhere else, switched apps, etc. —
|
||||
the focus controller's "leave" signal fires and we tear down the
|
||||
menu tree. Submenus open from inside the top-level popup, so we
|
||||
defer the actual close to an idle callback: that gives the new
|
||||
submenu a chance to take focus first, and we only close if none of
|
||||
our windows still has it. */
|
||||
static gboolean any_popup_has_focus(void) {
|
||||
if (popup_win && gtk_window_has_toplevel_focus(GTK_WINDOW(popup_win)))
|
||||
if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win)))
|
||||
return TRUE;
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
if (gtk_window_has_toplevel_focus(GTK_WINDOW(l->data)))
|
||||
if (gtk_window_is_active(GTK_WINDOW(l->data)))
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
@@ -321,17 +325,90 @@ static gboolean any_popup_has_focus(void) {
|
||||
|
||||
static gboolean focus_out_recheck(gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (!any_popup_has_focus()) {
|
||||
if (!any_popup_has_focus())
|
||||
close_all_popups();
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
|
||||
gpointer user_data) {
|
||||
(void)widget; (void)event; (void)user_data;
|
||||
static void on_popup_focus_leave(GtkEventControllerFocus *ctrl,
|
||||
gpointer user_data) {
|
||||
(void)ctrl; (void)user_data;
|
||||
g_idle_add(focus_out_recheck, NULL);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Attach a focus controller that fires close_all_popups on focus loss. */
|
||||
static void attach_outside_click_close(GtkWidget *win) {
|
||||
GtkEventController *focus = gtk_event_controller_focus_new();
|
||||
g_signal_connect(focus, "leave",
|
||||
G_CALLBACK(on_popup_focus_leave), NULL);
|
||||
gtk_widget_add_controller(win, focus);
|
||||
}
|
||||
|
||||
/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the
|
||||
GdkSurface is mapped to a real X11 Window we can reposition with
|
||||
XMoveWindow. Must be called after the window has been realized (i.e.
|
||||
after gtk_widget_set_visible TRUE).
|
||||
|
||||
The popup is **not** override-redirect — the WM keeps managing it so
|
||||
focus tracking still works (focus-out fires when the user clicks
|
||||
elsewhere). We tag the window with a stack of EWMH hints that make
|
||||
sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a
|
||||
floating menu: above the tray panel, skipped from taskbar/pager,
|
||||
no decorations. */
|
||||
static void x11_move_window(GtkWidget *win, int x, int y) {
|
||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win));
|
||||
if (!surface || !GDK_IS_X11_SURFACE(surface))
|
||||
return;
|
||||
Window xid = gdk_x11_surface_get_xid(surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
|
||||
/* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc
|
||||
render the window above panels and skip decorations. Must be
|
||||
set before the window is mapped to be honoured by some WMs;
|
||||
on already-mapped windows it works for most modern WMs but a
|
||||
few need an unmap/map cycle to re-read the property. */
|
||||
Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False);
|
||||
Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
|
||||
XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)&wm_type_popup, 1);
|
||||
|
||||
/* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into
|
||||
one property write. */
|
||||
Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False);
|
||||
Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False);
|
||||
Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False);
|
||||
Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False);
|
||||
Atom states[3] = { state_above, state_skip_tb, state_skip_pg };
|
||||
XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)states, 3);
|
||||
|
||||
XMoveWindow(xdpy, xid, x, y);
|
||||
XRaiseWindow(xdpy, xid);
|
||||
|
||||
/* POPUP_MENU windows aren't given keyboard focus by most WMs (the
|
||||
spec says they're "menus", which traditionally use a grab rather
|
||||
than focus). Without focus GtkEventControllerFocus's leave signal
|
||||
never fires, so we'd have no way to notice the user clicking
|
||||
elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW
|
||||
(source=2 means "pager / pseudo-user request" which most WMs
|
||||
honour without timestamp checks). This is safer than calling
|
||||
XSetInputFocus directly — that races the X server with the
|
||||
not-yet-fully-mapped window and trips BadMatch. */
|
||||
Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False);
|
||||
XClientMessageEvent ev;
|
||||
memset(&ev, 0, sizeof(ev));
|
||||
ev.type = ClientMessage;
|
||||
ev.window = xid;
|
||||
ev.message_type = net_active;
|
||||
ev.format = 32;
|
||||
ev.data.l[0] = 2; /* source: pager */
|
||||
ev.data.l[1] = CurrentTime;
|
||||
XSendEvent(xdpy, DefaultRootWindow(xdpy), False,
|
||||
SubstructureRedirectMask | SubstructureNotifyMask,
|
||||
(XEvent *)&ev);
|
||||
|
||||
XFlush(xdpy);
|
||||
}
|
||||
|
||||
/* Forward declaration — submenu buttons need to schedule a child popup. */
|
||||
@@ -346,55 +423,81 @@ typedef struct {
|
||||
static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
submenu_open_data *sd = (submenu_open_data *)user_data;
|
||||
|
||||
GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
GtkWidget *win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(win), TRUE);
|
||||
|
||||
g_signal_connect(win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(sd->items, sd->count);
|
||||
gtk_container_add(GTK_CONTAINER(win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(win), vbox);
|
||||
|
||||
/* GtkButton has no native GdkWindow of its own — gtk_widget_get_window
|
||||
returns the parent popup's window. To get the button's screen-space
|
||||
position we read the popup origin (ox, oy) and add the button's
|
||||
allocation within the popup. */
|
||||
gint ox, oy;
|
||||
gdk_window_get_origin(gtk_widget_get_window(GTK_WIDGET(btn)), &ox, &oy);
|
||||
GtkAllocation alloc;
|
||||
gtk_widget_get_allocation(GTK_WIDGET(btn), &alloc);
|
||||
int ax = ox + alloc.x;
|
||||
int ay = oy + alloc.y;
|
||||
/* Need the anchor button's position in root coordinates. GTK4
|
||||
removed gtk_widget_translate_coordinates(); compute via the
|
||||
button's bounds within its native widget plus the native
|
||||
surface's screen origin via X11. */
|
||||
graphene_rect_t bounds;
|
||||
if (!gtk_widget_compute_bounds(GTK_WIDGET(btn),
|
||||
GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))),
|
||||
&bounds)) {
|
||||
bounds.origin.x = 0;
|
||||
bounds.origin.y = 0;
|
||||
bounds.size.width = 0;
|
||||
bounds.size.height = 0;
|
||||
}
|
||||
GdkSurface *anchor_surface =
|
||||
gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn)));
|
||||
int ox = 0, oy = 0;
|
||||
if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) {
|
||||
Window axid = gdk_x11_surface_get_xid(anchor_surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(anchor_surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
Window child;
|
||||
XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy),
|
||||
0, 0, &ox, &oy, &child);
|
||||
}
|
||||
int ax = ox + (int)bounds.origin.x;
|
||||
int ay = oy + (int)bounds.origin.y;
|
||||
|
||||
gtk_widget_show_all(win);
|
||||
gint sw, sh;
|
||||
gtk_window_get_size(GTK_WINDOW(win), &sw, &sh);
|
||||
gtk_widget_set_visible(win, TRUE);
|
||||
|
||||
int sw, sh;
|
||||
gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh);
|
||||
if (sw <= 0 || sh <= 0) {
|
||||
/* default_size returns -1,-1 if never explicitly set; fall back
|
||||
to the measured preferred size. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(win, NULL, &req);
|
||||
sw = req.width;
|
||||
sh = req.height;
|
||||
}
|
||||
|
||||
/* The parent popup grows upward from the tray, so submenu items
|
||||
sit closer to the bottom of the screen than to the top. Align
|
||||
the submenu's BOTTOM to the anchor button's bottom: the popup
|
||||
grows upward, level with the row that opened it. Don't clamp
|
||||
to the monitor top — that would re-position the submenu next
|
||||
to an unrelated sibling row above the anchor. */
|
||||
int final_x = ax + alloc.width;
|
||||
int final_y = ay + alloc.height - sh;
|
||||
grows upward, level with the row that opened it. */
|
||||
int final_x = ax + (int)bounds.size.width;
|
||||
int final_y = ay + (int)bounds.size.height - sh;
|
||||
|
||||
/* Horizontal flip against the monitor under the anchor button. */
|
||||
GdkDisplay *display = gtk_widget_get_display(win);
|
||||
GdkMonitor *monitor = gdk_display_get_monitor_at_point(display, ax, ay);
|
||||
if (monitor) {
|
||||
GListModel *monitors = gdk_display_get_monitors(display);
|
||||
guint n = g_list_model_get_n_items(monitors);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i);
|
||||
GdkRectangle geom;
|
||||
gdk_monitor_get_geometry(monitor, &geom);
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
gdk_monitor_get_geometry(m, &geom);
|
||||
if (ax >= geom.x && ax < geom.x + geom.width &&
|
||||
ay >= geom.y && ay < geom.y + geom.height) {
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
g_object_unref(m);
|
||||
break;
|
||||
}
|
||||
g_object_unref(m);
|
||||
}
|
||||
|
||||
gtk_window_move(GTK_WINDOW(win), final_x, final_y);
|
||||
x11_move_window(win, final_x, final_y);
|
||||
gtk_window_present(GTK_WINDOW(win));
|
||||
|
||||
submenu_popups = g_list_prepend(submenu_popups, win);
|
||||
@@ -402,8 +505,7 @@ static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
|
||||
/* Build a vbox of GtkWidgets for the supplied items. Used for both the
|
||||
top-level popup and each submenu popup. The submenu_open_data attached
|
||||
to submenu buttons is freed when the submenu_popups list is cleared
|
||||
(we use the button's "destroy" signal). */
|
||||
to submenu buttons is freed when the button is destroyed. */
|
||||
static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) {
|
||||
(void)widget;
|
||||
free(user_data);
|
||||
@@ -417,19 +519,21 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
if (mi->is_separator) {
|
||||
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
|
||||
gtk_widget_set_margin_top(sep, 2);
|
||||
gtk_widget_set_margin_bottom(sep, 2);
|
||||
gtk_box_append(GTK_BOX(vbox), sep);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mi->is_check) {
|
||||
GtkWidget *chk = gtk_check_button_new_with_label(
|
||||
mi->label ? mi->label : "");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
|
||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked);
|
||||
gtk_widget_set_sensitive(chk, mi->enabled);
|
||||
g_signal_connect(chk, "toggled",
|
||||
G_CALLBACK(on_check_toggled),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), chk);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -447,9 +551,10 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
GtkWidget *btn = gtk_button_new_with_label(label_text);
|
||||
gtk_widget_set_sensitive(btn, mi->enabled);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
GtkWidget *lbl = gtk_bin_get_child(GTK_BIN(btn));
|
||||
if (lbl) gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE);
|
||||
GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn));
|
||||
if (GTK_IS_LABEL(lbl))
|
||||
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
|
||||
free(display_label);
|
||||
|
||||
@@ -468,7 +573,7 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
G_CALLBACK(on_button_clicked),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
}
|
||||
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), btn);
|
||||
}
|
||||
|
||||
return vbox;
|
||||
@@ -480,38 +585,35 @@ static gboolean popup_menu_idle(gpointer user_data) {
|
||||
/* Destroy old top-level (and orphan submenus) before rebuilding. */
|
||||
close_all_popups();
|
||||
if (popup_win) {
|
||||
gtk_widget_destroy(popup_win);
|
||||
gtk_window_destroy(GTK_WINDOW(popup_win));
|
||||
popup_win = NULL;
|
||||
}
|
||||
|
||||
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
|
||||
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
popup_win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
|
||||
|
||||
/* Close on focus loss. */
|
||||
g_signal_connect(popup_win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(popup_win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(pd->items, pd->count);
|
||||
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(popup_win), vbox);
|
||||
|
||||
gtk_widget_show_all(popup_win);
|
||||
gtk_widget_set_visible(popup_win, TRUE);
|
||||
|
||||
/* Position the window above the click point (menu grows upward
|
||||
from tray). Use measured preferred size — default_size is -1
|
||||
until set. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(popup_win, NULL, &req);
|
||||
int win_w = req.width;
|
||||
int win_h = req.height;
|
||||
|
||||
/* Position the window above the click point (menu grows upward from tray). */
|
||||
gint win_w, win_h;
|
||||
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
|
||||
int final_x = pd->x - win_w / 2;
|
||||
int final_y = pd->y - win_h;
|
||||
if (final_x < 0) final_x = 0;
|
||||
if (final_y < 0) final_y = pd->y; /* fallback: below click */
|
||||
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
|
||||
x11_move_window(popup_win, final_x, final_y);
|
||||
|
||||
/* Grab focus so focus-out-event works. */
|
||||
gtk_window_present(GTK_WINDOW(popup_win));
|
||||
|
||||
/* The vbox+children retain pointers into pd->items (via submenu
|
||||
|
||||
4
go.mod
@@ -103,7 +103,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -191,7 +191,7 @@ require (
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.19.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.19.1 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
||||
8
go.sum
@@ -194,8 +194,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
|
||||
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
@@ -703,8 +703,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94 h1:c/0ZZTj3BFbZQD1s5KHwsshlhunH6YC++gt+cGYV6qA=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.94/go.mod h1:4cKvtUppwqYC9tVtvgHWzEmXfUnuLEV3q8d0Jh6xkQQ=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95 h1:Rve8djRSldn6381q2l8gw8XEnzPX/4So6VsRM6bc7Vs=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.95/go.mod h1:3euiK0wb6vnXvxiHysRYYbukCa060bLSsfrvN7sZg4k=
|
||||
github.com/wailsapp/wails/webview2 v1.0.24 h1:uULnjCSaRfMlU84mS3kjLgPsRosEOIusVK1nFOHZHzs=
|
||||
github.com/wailsapp/wails/webview2 v1.0.24/go.mod h1:sdf+s0nAdxlzVWf9SCxC15XaxnQPJeY+uU1Ucn3jHQM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
|
||||