Compare commits

..

12 Commits

Author SHA1 Message Date
Zoltán Papp
8d4f35352f skip About-row brand mark on macOS
NSMenuItem.setImage stretches the row to the leading image's pixel
size regardless of the surrounding rows, so any non-empty bitmap on
the About entry made it visibly taller than the rest of the tray
menu — leaving 16, 18 or 22 px versions all looking wrong next to
the unadorned rows above and below.

Drop the macOS brand mark and gate the SetBitmap call on a non-empty
byte slice; iconMenuNetbird is now nil on macOS, so the About row
falls back to text only. Windows and Linux still ship the brand mark
through their per-platform embed files.
2026-05-21 17:01:08 +02:00
Zoltán Papp
85029898a5 per-platform tray menu icons and Windows-specific status row
The Windows menu renderer paints leading bitmaps into the Win32
check-mark slot (SetMenuItemBitmaps), which differs from how Cocoa
and GTK handle NSMenuItem.image / menu-row icons:

  - SM_CXMENUCHECK sizing: Windows expects ~16x16 at 100% DPI in the
    check-mark slot and visually overflows the row for anything bigger.
  - Disabled-state mask: Windows desaturates both the row text and the
    bitmap when MFS_DISABLED is set, so a disabled informational row
    renders the coloured status dot in greyscale.

Per the platform icon guidelines:

  Platform | Size           | Notes
  ---------|----------------|-----------------------------------------
  Windows  | 16x16          | check-mark slot, status row stays enabled
  macOS    | 22x22 (18-22)  | NSMenuItem leading image, HIG
  Linux    | 24x24 (22-48)  | GTK4 menu-row icon channel

Changes:

  * Split the menu-row icon embeds into icons_menu_{windows,darwin,linux}.go
    so each platform pulls its own size; the brand mark is rendered from
    assets/svg/netbird-menu.svg (new vector source) at 16/22/24 px with
    Inkscape, and the Windows status dots ship as 8x8 content centred on
    a 16x16 transparent canvas (the renderer upscales the bitmap, so the
    padding keeps the dot visually proportional to the row text).

  * Introduce statusRowEnabled() in tray_status_enabled_{windows,other}.go:
    true on Windows so the disabled-state mask does not strip the dot's
    colour; false on macOS/Linux where disabled menu rows fade the label
    without desaturating the leading bitmap, signalling that the row is
    informational.

  * Add an icon to the About submenu using the same brand mark.
2026-05-21 16:41:52 +02:00
Zoltán Papp
c3aeb5be15 force dark window theme on Windows 2026-05-21 14:59:00 +02:00
Eduard Gert
df61f22d96 add error msg to profile context and auto update 2026-05-21 09:49:32 +02:00
Eduard Gert
32df29bbd4 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts:
#	client/ui/frontend/src/screens/Update.tsx
2026-05-21 09:34:45 +02:00
Zoltán Papp
0a458ead8b port xembed tray popup menu from gtk3 to gtk4 2026-05-20 19:53:24 +02:00
Zoltan Papp
aab8274b1a clear connect-action latch when external disconnect cancels Connecting
The main-window toggle stayed visually stuck on "Connecting" when the
user clicked Connect in the UI and then clicked Disconnect in the
tray (or the daemon was otherwise cancelled mid-Connecting).

Repro: open the main window, click the toggle to Connect, then while
the daemon is still in Connecting click Disconnect in the tray menu.
The tray and daemon agree the session is Idle, but the React toggle
keeps painting "Connecting" until the next manual interaction.

Root cause is in ConnectionStatusSwitch.tsx. The component holds an
`action` latch ("connect" | "logging-in" | "disconnect" | null) so the
toggle can show an optimistic transitional state while the daemon
catches up. The connState memo treats `action === "connect"` plus any
non-Connected daemon state as Connecting:

    if ((action === "connect" || action === "logging-in") &&
        daemonState !== "Connected") {
        return ConnectionState.Connecting;
    }

The effect that releases the latch only cleared it on `Connected` or
`DaemonUnavailable`. There was no branch for "the connect flow was
cancelled externally and the daemon is back at Idle", so the latch
remained set forever and the optimistic Connecting state never
collapsed.

Fix: add a `sawConnectingRef` that flips to true the first time the
daemon reports Connecting during an active "connect" action, and
resets when `action` returns to null. When `action === "connect"` and
the daemon flips from a state we'd observed as Connecting back to
Idle, clear the latch so connState falls through to Disconnected.

Other paths are untouched:
- Successful connect still clears on Connected.
- NeedsLogin still hands off to driveLogin.
- DaemonUnavailable still clears via the `unreachable` branch.
- The `"logging-in"` action is intentionally not handled here; Login's
  internal Down flaps the daemon through Idle and driveLogin's
  .finally remains the sole clearer for that latch.
- The `"disconnect"` action's Idle/Disconnected/unreachable clear is
  unchanged.
2026-05-20 19:44:02 +02:00
Zoltan Papp
d3b660afba classify daemon login errors and surface localised dialogs
The daemon returns gRPC errors whose message is a wrapped mgm + JWT
stack (e.g. "invalid jwt token, err: token could not be parsed: ...").
Showing that in a native dialog is unreadable. Connection now maps the
substrings it recognises to a ClientError{code, short, long} so the UI
can render a localised summary plus a Details: block carrying the raw
daemon text. formatErrorMessage on the TS side reads the structured
payload from Wails' Error.cause (or the JSON-stringified Error.message)
and falls back to plain Error.message for callers not yet migrated.

Also bumps Wails to v3.0.0-alpha.95.
2026-05-20 19:13:13 +02:00
Zoltán Papp
341848b1ae fix lint issues in session watcher tests and status humaniser 2026-05-20 18:46:56 +02:00
Eduard Gert
414e7815e4 update default view icon, remove capitalize from profile name 2026-05-20 16:45:06 +02:00
Eduard Gert
a7b26e3c0d add updating dialog 2026-05-20 16:20:40 +02:00
Eduard Gert
42534b24c5 fix scrollarea inside settings 2026-05-20 13:43:18 +02:00
56 changed files with 2609 additions and 2363 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 />}>

View File

@@ -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 && (

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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;
}

View File

@@ -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")}

View 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);
};

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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: "" };
}
}

View File

@@ -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}`}
>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -39,6 +39,7 @@ export const Settings = () => {
<SettingsNavigationTriggers />
<MainRightSide>
<ScrollArea.Root
key={active}
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>

View File

@@ -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;

View File

@@ -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>
</>
);
}

View File

@@ -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)

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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.

View File

@@ -0,0 +1,41 @@
//go:build darwin
package main
import _ "embed"
// 22x22 status dot icons used on macOS. Apple's HIG recommends an
// 1822 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

View 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
// 2248 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

View 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

View File

@@ -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,
},

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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 {

View 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 }

View 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 }

View File

@@ -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>

View File

@@ -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
View File

@@ -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
View File

@@ -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=