Compare commits

..

1 Commits

Author SHA1 Message Date
Zoltán Papp
dfee5252a3 client/ui: open main window on tray left-click on Linux
KDE Plasma routes a tray left-click to the SNI Activate method (right-click
opens the context menu), but NetBird wired no Activate action, so on KDE a
left-click appeared completely dead while only right-click surfaced the menu.

Bind the Linux tray OnClick handler to ShowWindow(). OpenMenu() is not an
option on Linux: Wails v3 leaves linuxSystemTray.openMenu unimplemented (it
only logs), so left-click→OpenMenu would still do nothing on KDE. ShowWindow()
is the same call Windows already runs from its double-click handler, and it
does not reproduce the macOS OpenMenu freeze (c77e5cef8) — that came from
NSStatusItem's blocking embedded menu loop, whereas Show/Focus return
immediately.

Split the Linux click handler into its own tray_click_linux.go and narrow the
macOS no-op bindTrayClick build tag accordingly. The context menu stays on
right-click on every host. On hosts that already open the menu on left-click
natively (GNOME Shell + AppIndicator) left-click now opens the window instead;
the menu remains on right-click.
2026-06-01 22:04:49 +02:00
189 changed files with 1981 additions and 5856 deletions

View File

@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: en-US
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
auto_review:
enabled: true
drafts: false
path_filters:
- "!**/*.tsx"
- "!**/*.ts"
- "!**/*.js"
- "!**/*.svg"
chat:
auto_reply: true

View File

@@ -6,6 +6,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
iptables=1.8.9-2 \
libgl1-mesa-dev=22.3.6-1+deb12u1 \
xorg-dev=1:7.7+23 \
libayatana-appindicator3-dev=0.5.92-1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& go install -v golang.org/x/tools/gopls@latest

View File

@@ -53,11 +53,5 @@ jobs:
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,client

View File

@@ -53,7 +53,7 @@ jobs:
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
- name: Install 32-bit libpcap
if: steps.cache.outputs.cache-hit != 'true'
@@ -145,7 +145,7 @@ jobs:
${{ runner.os }}-gotest-cache-
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
- name: Install 32-bit libpcap
if: matrix.arch == '386'
@@ -166,15 +166,7 @@ jobs:
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,client
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
test_client_on_docker:
name: "Client (Docker) / Unit"
@@ -292,17 +284,9 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
-exec 'sudo' -coverprofile=coverage.txt \
-exec 'sudo' \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,relay
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
@@ -350,15 +334,7 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,proxy
go test -timeout 10m -p 1 ./proxy/...
test_signal:
name: "Signal / Unit"
@@ -409,17 +385,9 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
-exec 'sudo' -coverprofile=coverage.txt \
-exec 'sudo' \
-timeout 10m ./signal/... ./shared/signal/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,signal
test_management:
name: "Management / Unit"
needs: [build-cache]
@@ -485,18 +453,10 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=devcert -coverprofile=coverage.txt \
go test -tags=devcert \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: unit,management
benchmark:
name: "Management / Benchmark"
needs: [build-cache]
@@ -735,14 +695,6 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=integration -coverprofile=coverage.txt \
go test -tags=integration \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
flags: integration,management

View File

@@ -24,13 +24,9 @@ jobs:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
# Non-English UI translations trip codespell on real foreign words
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
# truth that should be spell-checked. List each translated locale
# dir below and add new ones as languages are added under
# client/ui/i18n/locales/. Single-star globs are matched per path
# segment by codespell and behave the same across versions; the
# recursive "**" form did not take effect with the codespell shipped
# by this action.
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/hu/*
# truth that should be spell-checked. Add each new locale dir here
# when a language is added under client/ui/i18n/locales/.
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json,client/ui/i18n/locales/de/**,client/ui/i18n/locales/hu/**
golangci:
strategy:
fail-fast: false
@@ -62,7 +58,7 @@ jobs:
cache: false
- name: Install dependencies
if: matrix.os == 'ubuntu-latest'
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: Stub Wails frontend bundle
# client/ui/main.go has //go:embed all:frontend/dist. The
# directory is produced by `pnpm run build` and is gitignored, so

View File

@@ -20,30 +20,15 @@ jobs:
per_page: 100,
});
// Cover renamed .pb.go files in addition to plain edits.
// Renamed entries land under the new path with previous_filename
// pointing at the base-side name, so we read the base content
// from the old path when present.
const changedPbFiles = files
.filter(f => (f.status === 'modified' || f.status === 'renamed')
&& f.filename.endsWith('.pb.go'))
.map(f => ({
headPath: f.filename,
basePath: f.previous_filename || f.filename,
}));
if (changedPbFiles.length === 0) {
console.log('No modified or renamed .pb.go files to check');
const modifiedPbFiles = files.filter(
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
);
if (modifiedPbFiles.length === 0) {
console.log('No modified .pb.go files to check');
return;
}
// Matches the generator version headers protoc writes at the top
// of generated files:
// // protoc v3.21.12
// // protoc-gen-go v1.26.0
// // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
// The optional "- " prefix and the optional -gen-go / -gen-go-grpc
// suffixes keep the *_grpc.pb.go headers in scope.
const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
const baseSha = context.payload.pull_request.base.sha;
const headSha = context.payload.pull_request.head.sha;
@@ -70,22 +55,20 @@ jobs:
}
const violations = [];
for (const file of changedPbFiles) {
for (const file of modifiedPbFiles) {
const [base, head] = await Promise.all([
getVersionHeader(file.basePath, baseSha),
getVersionHeader(file.headPath, headSha),
getVersionHeader(file.filename, baseSha),
getVersionHeader(file.filename, headSha),
]);
if (!base.ok || !head.ok) {
core.warning(
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
);
continue;
}
if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
file: file.basePath === file.headPath
? file.headPath
: `${file.basePath} → ${file.headPath}`,
file: file.filename,
base: base.lines,
head: head.lines,
});

View File

@@ -367,10 +367,7 @@ jobs:
version: 11
- name: Install dependencies
# GTK4/WebKitGTK 6.0 dev libs for the default build + GTK3/WebKit2GTK 4.1
# dev libs for the legacy -tags gtk3 build (netbird-ui-gtk3). Both stacks
# coexist on the same runner; goreleaser builds both Linux variants here.
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64-x86-64
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64
- name: Decode GPG signing key
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -27,7 +27,7 @@ jobs:
with:
go-version-file: "go.mod"
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: Install golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:

View File

@@ -196,7 +196,6 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_deb
bindir: /usr/bin
builds:
@@ -211,7 +210,6 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_rpm
bindir: /usr/bin
builds:

View File

@@ -24,25 +24,6 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). -tags gtk3 flips Wails to
# the GTK3 stack and drops our GTK4-only XEmbed tray host (see
# xembed_host_gtk3_linux.go). Removed upstream in Wails v3.1.
- id: netbird-ui-gtk3
dir: client/ui
binary: netbird-ui
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=gtk3
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
- id: netbird-ui-windows-amd64
dir: client/ui
binary: netbird-ui
@@ -79,10 +60,6 @@ archives:
name_template: "{{ .ProjectName }}-linux_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui
- id: linux-gtk3-arch
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
- id: windows-arch
name_template: "{{ .ProjectName }}-windows_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
@@ -93,8 +70,6 @@ nfpms:
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb
package_name: netbird-ui
builds:
@@ -105,19 +80,18 @@ nfpms:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
dst: /usr/share/applications/netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- libgtk-4-1
- libwebkitgtk-6.0-4
- libgtk-3-0
- libwebkit2gtk-4.1-0
- libayatana-appindicator3-1
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm
package_name: netbird-ui
builds:
@@ -128,66 +102,14 @@ nfpms:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- gtk4
- webkitgtk6.0
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
# Legacy GTK3 deb for Ubuntu 22.04 / Debian 12 (no WebKitGTK 6.0). Same
# package_name as the GTK4 deb -- the two are mutually-exclusive alternatives
# served from the matching distro repo; the package manager resolves by deps.
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb_gtk3
package_name: netbird-ui
builds:
- netbird-ui-gtk3
formats:
- deb
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Legacy GTK3 rpm for RHEL 9 / Fedora <=39 (no WebKitGTK 6.0).
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm_gtk3
package_name: netbird-ui
builds:
- netbird-ui-gtk3
formats:
- rpm
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
dst: /usr/share/applications/netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- gtk3
- webkit2gtk4.1
- libayatana-appindicator-gtk3
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
@@ -208,22 +130,3 @@ uploads:
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT
# Legacy GTK3 packages share the netbird-ui name, so they must live in a repo
# path the old distros point at -- here a dedicated `gtk3` distribution/path.
# TODO: confirm the final repo layout with the pkgs.wiretrustee.com owner.
- name: debian-gtk3
ids:
- netbird_ui_deb_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
username: dev@wiretrustee.com
method: PUT
- name: yum-gtk3
ids:
- netbird_ui_rpm_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT

View File

@@ -291,6 +291,8 @@ go test -exec sudo ./...
```
> On Windows use a powershell with administrator privileges
> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed
## Checklist before submitting a PR
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
- Keep functions as simple as possible, with a single purpose

View File

@@ -19,7 +19,6 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
"github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -101,7 +100,6 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -300,7 +298,6 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -435,7 +432,6 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,

View File

@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,14 +112,8 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
if consoleLog {
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
} else {
if err := util.InitLog(logLevel, logFiles...); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
if err := util.InitLog(logLevel, logFiles...); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
cfg, err := newSVCConfig()
@@ -144,7 +138,7 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {
return err
}
@@ -158,7 +152,7 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {
return err
}
@@ -176,7 +170,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {
return err
}
@@ -194,7 +188,7 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {
return err
}
@@ -212,7 +206,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {
return err
}

View File

@@ -12,13 +12,7 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
out := version.NetbirdVersion()
if version.IsDevelopmentVersion(out) {
if commit := version.NetbirdCommit(); commit != "" {
out += "-" + commit
}
}
cmd.Println(out)
cmd.Println(version.NetbirdVersion())
},
}
)

View File

@@ -362,10 +362,6 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
if pc := f.endpoint.capture.Load(); pc != nil {
(*pc).Offer(fullPacket, true)
}
return len(fullPacket)
}

View File

@@ -33,14 +33,6 @@ const (
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
// can show "expires in ~N minutes" without hardcoding either constant.
MetaSessionLeadMinutes = "lead_minutes"
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
// SystemEvent the daemon emits when it discards a deadline from the
// management server (pre-epoch, too far in the future, or past the
// clock-skew tolerance). The value is the rejection reason string.
// userMessage is left empty; the UI detects the event via this key
// and builds a localized notification — same pattern as the session
// warnings above.
MetaSessionDeadlineRejected = "session_deadline_rejected"
)
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/netip"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -356,11 +355,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
// Leave StateDir empty when there is no state path so a disk-backed
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
if path != "" {
engineConfig.StateDir = filepath.Dir(path)
}
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)

View File

@@ -254,8 +254,6 @@ type BundleGenerator struct {
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
daemonVersion string
cliVersion string
anonymize bool
includeSystemInfo bool
@@ -280,8 +278,6 @@ type GeneratorDependencies struct {
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -303,8 +299,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -465,11 +459,9 @@ func (g *BundleGenerator) addStatus() error {
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize,
ProfileName: profName,
DaemonVersion: g.daemonVersion,
Anonymize: g.anonymize,
ProfileName: profName,
})
overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -1047,8 +1039,7 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
return
}
// This regex will match both logs rotated by us and logrotate on linux
pattern := filepath.Join(logDir, "client*.log.*")
pattern := filepath.Join(logDir, "client-*.log.gz")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1081,12 +1072,7 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if strings.HasSuffix(name, ".gz") {
err = g.addSingleLogFileGz(files[i], name)
} else {
err = g.addSingleLogfile(files[i], name)
}
if err != nil {
if err := g.addSingleLogFileGz(files[i], name); err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}

View File

@@ -1,103 +0,0 @@
package debug
import (
"archive/zip"
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
// and gzipped), and skips unrelated files.
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
logrotatePlain := "client.log.1"
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
logrotateGz := "client.log.2.gz"
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
names := runAddRotatedLogFiles(t, dir, 10)
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
}
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
// logFileCount rotated files are bundled, ordered by mtime.
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
dir := t.TempDir()
oldest := filepath.Join(dir, "client.log.3")
middle := filepath.Join(dir, "client.log.2")
newest := filepath.Join(dir, "client.log.1")
writeFile(t, oldest, "old\n")
writeFile(t, middle, "mid\n")
writeFile(t, newest, "new\n")
now := time.Now()
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
require.NoError(t, os.Chtimes(newest, now, now))
names := runAddRotatedLogFiles(t, dir, 2)
require.Contains(t, names, "client.log.1")
require.Contains(t, names, "client.log.2")
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
}
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
// zip writer and returns the set of entry names that ended up in the archive.
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
t.Helper()
var buf bytes.Buffer
g := &BundleGenerator{
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(dir)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
require.NoError(t, err)
names := make(map[string]struct{}, len(zr.File))
for _, f := range zr.File {
names[f.Name] = struct{}{}
}
return names
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
func writeGzFile(t *testing.T, path, content string) {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
_, err := io.WriteString(gw, content)
require.NoError(t, err)
require.NoError(t, gw.Close())
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
}

View File

@@ -22,6 +22,7 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -55,7 +56,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
@@ -72,7 +72,6 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
"github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -149,10 +148,6 @@ type EngineConfig struct {
LogPath string
TempDir string
// StateDir is the directory holding the state file. The sync response
// (network map) is serialized here on platforms that persist it to disk.
StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -231,15 +226,10 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
// Sync response persistence (protected by syncRespMux).
// syncStore is nil unless persistence has been enabled; its presence is
// what marks persistence as active. The backend (disk or memory) is
// selected per-platform; see the syncstore package. syncStoreDir is where
// a disk-backed store serializes to.
syncRespMux sync.RWMutex
syncStore syncstore.Store
syncStoreDir string
// Sync response persistence (protected by syncRespMux)
syncRespMux sync.RWMutex
persistSyncResponse bool
latestSyncResponse *mgmProto.SyncResponse
flowManager nftypes.FlowManager
// auto-update
@@ -316,7 +306,6 @@ func NewEngine(
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
updateManager: services.UpdateManager,
syncStoreDir: config.StateDir,
}
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
// session expiry deadline. Deadline-change ticks come for free via
@@ -954,27 +943,26 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
// Read the storage-enabled flag under the syncRespMux too.
e.syncRespMux.RLock()
enabled := e.persistSyncResponse
e.syncRespMux.RUnlock()
// Store sync response if persistence is enabled
if enabled {
e.syncRespMux.Lock()
e.latestSyncResponse = update
e.syncRespMux.Unlock()
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
return err
}
// Persist sync response only after updateNetworkMap accepted and applied the update,
// so GetLatestSyncResponse() never returns state the engine did not actually apply.
// Done under the dedicated lock (syncRespMux), not under syncMsgMux.
// A non-nil syncStore is what marks persistence as enabled. Hold the lock for
// the whole Set so the store cannot be cleared (disabled / engine close)
// mid-call and have this write resurrect a file that was just removed.
e.syncRespMux.RLock()
if e.syncStore != nil {
if err := e.syncStore.Set(update); err != nil {
log.Errorf("failed to persist sync response: %v", err)
} else {
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
}
}
e.syncRespMux.RUnlock()
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
return nil
@@ -1106,7 +1094,6 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
@@ -1185,7 +1172,6 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
e.RunHealthProbes(e.ctx, true)
},
@@ -1858,18 +1844,6 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
// Drop any persisted sync response so its network map does not linger on
// disk after the engine stops (and cannot leak into a later run).
e.syncRespMux.Lock()
store := e.syncStore
e.syncStore = nil
e.syncRespMux.Unlock()
if store != nil {
if err := store.Clear(); err != nil {
log.Warnf("failed to clear persisted sync response on close: %v", err)
}
}
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
@@ -2212,42 +2186,45 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
// SetSyncResponsePersistence enables or disables sync response persistence.
// The store is only instantiated while persistence is enabled; construction
// itself drops any stale data left over from an earlier run (see syncstore).
// SetSyncResponsePersistence enables or disables sync response persistence
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
if enabled == (e.syncStore != nil) {
if enabled == e.persistSyncResponse {
return
}
e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
if err := e.syncStore.Clear(); err != nil {
log.Warnf("failed to clear persisted sync response: %v", err)
}
e.syncStore = nil
return
e.latestSyncResponse = nil
}
e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
// Hold the lock for the whole Get so the store cannot be cleared
// (disabled / engine close) mid-call.
e.syncRespMux.RLock()
defer e.syncRespMux.RUnlock()
enabled := e.persistSyncResponse
latest := e.latestSyncResponse
e.syncRespMux.RUnlock()
if e.syncStore == nil {
if !enabled {
return nil, errors.New("sync response persistence is disabled")
}
//nolint:nilnil
return e.syncStore.Get()
if latest == nil {
//nolint:nilnil
return nil, nil
}
log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
if !ok {
return nil, fmt.Errorf("failed to clone sync response")
}
return sr, nil
}
// GetWgAddr returns the wireguard address
@@ -2283,7 +2260,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
if e.config.DisableServerRoutes || e.config.BlockInbound {
if e.config.DisableServerRoutes {
return
}

View File

@@ -9,8 +9,6 @@ import (
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
"github.com/netbirdio/netbird/client/system"
)
@@ -53,13 +51,6 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
// of sync with the warning timers.
if err := e.sessionWatcher.Update(deadline); err != nil {
log.Errorf("auth session deadline rejected: %v, clearing", err)
e.statusRecorder.PublishEvent(
cProto.SystemEvent_ERROR,
cProto.SystemEvent_AUTHENTICATION,
"session deadline rejected",
"",
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
)
}
}

View File

@@ -35,10 +35,5 @@ func (w noopSessionWatcher) Update(deadline time.Time) error {
return nil
}
func (noopSessionWatcher) Dismiss() {
// No-op: only suppresses the timer-driven final-warning, which this stub never arms.
}
func (noopSessionWatcher) Close() {
// No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
}
func (noopSessionWatcher) Dismiss() {}
func (noopSessionWatcher) Close() {}

View File

@@ -4,8 +4,6 @@ import (
"strings"
"github.com/hashicorp/go-version"
nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -13,7 +11,7 @@ var (
)
func IsSupported(agentVersion string) bool {
if nbversion.IsDevelopmentVersion(agentVersion) {
if agentVersion == "development" {
return true
}

View File

@@ -113,7 +113,6 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
WgPort int
Routes map[string]struct{}
}
@@ -335,12 +334,8 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
// PeerStateByIP returns the full peer State for the given tunnel IP.
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
// address so dual-stack peers are reachable on either family. Searches
// both d.peers and d.offlinePeers — peers that have been moved into
// the offline slice by ReplaceOfflinePeers are still part of the
// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
// need to recognise them rather than treating them as unknown. Returns
// the zero State and false when no peer matches or the input is empty.
// address so dual-stack peers are reachable on either family. Returns the
// zero State and false when no peer matches or the input is empty.
func (d *Status) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
@@ -353,11 +348,6 @@ func (d *Status) PeerStateByIP(ip string) (State, bool) {
return state, true
}
}
for _, state := range d.offlinePeers {
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
return state, true
}
}
return State{}, false
}
@@ -1528,7 +1518,6 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)

View File

@@ -90,28 +90,6 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
// been moved into the offline slice via ReplaceOfflinePeers. Callers
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
// rather than unknown — otherwise authentication / DNS filtering treats
// known-but-offline peers as foreign IPs.
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
status := NewRecorder("https://mgm")
req := require.New(t)
status.ReplaceOfflinePeers([]State{
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
})
state, ok := status.PeerStateByIP("100.64.0.20")
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
state, ok = status.PeerStateByIP("fd00::20")
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
}
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/netbirdio/netbird/util"
@@ -60,22 +59,3 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return nil
}
// RemoveProfileState deletes the per-profile state file (which holds the
// account email used for the SSO login hint and the UI display). Called after
// a successful logout so a logged-out profile no longer shows a stale account
// email. The state file only stores the email, so deleting it is equivalent to
// clearing it; the next SSO login recreates it. A missing file is not an error.
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
stateFile := filepath.Join(configDir, profileName+".state.json")
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove profile state: %w", err)
}
return nil
}

View File

@@ -1,99 +0,0 @@
package syncstore
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/util"
)
// syncResponseFileName is the name of the file the sync response is serialized
// to, placed inside the configured directory (the state directory).
const syncResponseFileName = "networkmap.pb"
// diskStore serializes the latest sync response to a file on disk instead of
// keeping it in memory. This trades disk I/O for a much smaller memory
// footprint, which matters on memory-constrained platforms (iOS).
type diskStore struct {
mu sync.Mutex
path string
}
// NewDiskStore returns a Store that serializes the sync response to a file in
// the given directory. If dir is empty it falls back to the OS temp directory.
//
// Any file left over from a previous run is removed on construction so a fresh
// store never reads stale data (e.g. another profile's network map).
func NewDiskStore(dir string) Store {
if dir == "" {
dir = os.TempDir()
}
s := &diskStore{
path: filepath.Join(dir, syncResponseFileName),
}
if err := s.Clear(); err != nil {
log.Warnf("failed to clear stale sync response file: %v", err)
}
return s
}
func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
if resp == nil {
return s.Clear()
}
bs, err := proto.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal sync response: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
return fmt.Errorf("write sync response to %s: %w", s.path, err)
}
log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
return nil
}
func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
bs, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
return nil, nil
}
return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
}
resp := &mgmProto.SyncResponse{}
if err := proto.Unmarshal(bs, resp); err != nil {
return nil, fmt.Errorf("unmarshal sync response: %w", err)
}
log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
return resp, nil
}
func (s *diskStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove sync response file %s: %w", s.path, err)
}
return nil
}

View File

@@ -1,9 +0,0 @@
//go:build ios
package syncstore
// New returns the platform default store. On iOS the sync response is
// serialized to disk (in dir) to keep it out of the constrained process memory.
func New(dir string) Store {
return NewDiskStore(dir)
}

View File

@@ -1,9 +0,0 @@
//go:build !ios
package syncstore
// New returns the platform default store. On all non-iOS platforms the sync
// response is kept in memory; dir is unused.
func New(_ string) Store {
return NewMemoryStore()
}

View File

@@ -1,56 +0,0 @@
package syncstore
import (
"fmt"
"sync"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// memoryStore keeps the latest sync response in memory.
type memoryStore struct {
mu sync.RWMutex
latest *mgmProto.SyncResponse
}
// NewMemoryStore returns a Store that keeps the sync response in memory.
func NewMemoryStore() Store {
return &memoryStore{}
}
func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
s.latest = resp
return nil
}
func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
s.mu.RLock()
latest := s.latest
s.mu.RUnlock()
if latest == nil {
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
return nil, nil
}
log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
if !ok {
return nil, fmt.Errorf("clone sync response")
}
return sr, nil
}
func (s *memoryStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
s.latest = nil
return nil
}

View File

@@ -1,29 +0,0 @@
// Package syncstore stores the latest Management sync response (which carries
// the network map) for debug bundle generation.
//
// The storage backend is selected at build time per operating system: on iOS
// the response is serialized to disk to keep it out of the (tightly
// constrained) process memory, while on all other platforms it is kept in
// memory. The backend is chosen by the New constructor; see factory_ios.go and
// factory_other.go.
package syncstore
import (
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
// Store persists the latest sync response and returns it on demand.
//
// Implementations must be safe for concurrent use.
type Store interface {
// Set stores the given sync response, replacing any previously stored one.
Set(resp *mgmProto.SyncResponse) error
// Get returns the stored sync response, or nil if none is stored.
// The returned value is an independent copy that the caller may retain.
Get() (*mgmProto.SyncResponse, error)
// Clear removes any stored sync response. It is safe to call when nothing
// is stored.
Clear() error
}

View File

@@ -19,6 +19,8 @@ import (
const (
latestVersion = "latest"
// this version will be ignored
developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -481,7 +483,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
if version.IsDevelopmentVersion(m.currentVersion) {
if m.currentVersion == developmentVersion {
log.Debugf("skipping auto-update, running development version")
return false
}

View File

@@ -1638,7 +1638,6 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1729,13 +1728,6 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
func (x *LocalPeerState) GetWgPort() int32 {
if x != nil {
return x.WgPort
}
return 0
}
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2753,7 +2745,6 @@ type DebugBundleRequest struct {
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2816,13 +2807,6 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
func (x *DebugBundleRequest) GetCliVersion() string {
if x != nil {
return x.CliVersion
}
return ""
}
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -6755,7 +6739,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6764,8 +6748,7 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
"\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
@@ -6843,17 +6826,14 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
"\n" +
"cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\"}\n" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +

View File

@@ -384,7 +384,6 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
@@ -513,7 +512,6 @@ message DebugBundleRequest {
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
string cliVersion = 6;
}
message DebugBundleResponse {

View File

@@ -1,16 +1,17 @@
#!/bin/bash
set -e
if ! which realpath >/dev/null 2>&1; then
echo realpath is not installed
echo run: brew install coreutils
exit 1
if ! which realpath > /dev/null 2>&1
then
echo realpath is not installed
echo run: brew install coreutils
exit 1
fi
old_pwd=$(pwd)
script_path=$(dirname "$(realpath "$0")")
script_path=$(dirname $(realpath "$0"))
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"

View File

@@ -14,7 +14,6 @@ import (
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
@@ -71,8 +70,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
DaemonVersion: version.NetbirdVersion(),
CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),

View File

@@ -793,22 +793,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, err
}
// StatusNeedsLogin is a legitimate fresh-start entry state: a successful
// WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
// done, the token is in hand, but the engine hasn't been brought up yet —
// see WaitSSOLogin's state-transition table). The same holds after a
// mid-session expiry tore the engine down (clientRunning == false) and the
// user re-authenticated. In both cases the caller's Up is expected to drive
// the connection; treat NeedsLogin like Idle and reset to Idle so the
// engine's own StatusConnecting → StatusConnected progression starts from a
// clean slate. Without this, the first Up after an SSO login fails with
// "up already in progress" and the user has to trigger Up a second time
// (CLI: re-run `netbird up`; GUI: click Connect again).
if status == internal.StatusNeedsLogin {
status = internal.StatusIdle
state.Set(internal.StatusIdle)
}
if status != internal.StatusIdle {
s.mutex.Unlock()
return nil, fmt.Errorf("up already in progress: current status %s", status)
@@ -959,10 +943,6 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
if msg != nil && msg.ProfileName != nil {
s.publishProfileListChanged(*msg.ProfileName)
}
return &proto.SwitchProfileResponse{}, nil
}
@@ -1214,19 +1194,7 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
}
}()
if err := mgmClient.Logout(); err != nil {
// The peer is already gone from the management server (e.g. deleted
// from the dashboard). The logout's goal — deregistering this peer —
// is therefore already satisfied, so treat NotFound as success rather
// than blocking the logout/profile-removal flow.
if logoutPeerGone(err) {
log.Infof("peer already removed from management server, treating logout as successful")
return nil
}
return err
}
return nil
return mgmClient.Logout()
}
// Status returns the daemon status
@@ -1880,8 +1848,6 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, fmt.Errorf("failed to create profile: %w", err)
}
s.publishProfileListChanged(msg.ProfileName)
return &proto.AddProfileResponse{}, nil
}
@@ -1903,32 +1869,9 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
s.publishProfileListChanged(msg.ProfileName)
return &proto.RemoveProfileResponse{}, nil
}
// publishProfileListChanged nudges the desktop UI to refresh its profile list
// after a CLI-driven add/remove. The daemon exposes no dedicated
// profile-changed RPC event, and a profile add/remove doesn't move the
// connection status, so the UI's SubscribeStatus path never fires for it (and
// the tray's status-string guard would swallow it anyway). Instead we publish
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
// recognises the metadata "kind" marker and translates it into its internal
// profile-changed signal that both the tray menu and the React profile views
// already subscribe to (see client/ui/services/daemon_feed.go,
// MetadataKindProfileListChanged). userMessage is intentionally empty so this
// stays a silent refresh signal rather than a user-facing notification.
func (s *Server) publishProfileListChanged(profileName string) {
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Profile list changed",
"",
map[string]string{"kind": "profile-list-changed", "profile": profileName},
)
}
// ListProfiles lists all profiles in the daemon.
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
s.mutex.Lock()
@@ -2133,15 +2076,3 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
}
return nil
}
// logoutPeerGone reports whether a management Logout failed because the peer
// no longer exists server-side (gRPC NotFound), walking the wrap chain since
// the client wraps the gRPC status with fmt.Errorf.
func logoutPeerGone(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
return true
}
}
return false
}

View File

@@ -147,7 +147,6 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -197,7 +196,6 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -571,21 +569,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
daemonVersion := "N/A"
if o.DaemonVersion != "" {
daemonVersion = o.DaemonVersion
}
cliVersion := version.NetbirdVersion()
if o.CliVersion != "" {
cliVersion = o.CliVersion
}
wgPortString := "N/A"
if o.WgPort > 0 {
wgPortString = fmt.Sprintf("%d", o.WgPort)
}
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -599,7 +582,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
"Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
@@ -608,8 +590,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
daemonVersion,
cliVersion,
o.DaemonVersion,
version.NetbirdVersion(),
o.ProfileName,
managementConnString,
signalConnString,
@@ -619,7 +601,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,

View File

@@ -94,7 +94,6 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -211,7 +210,6 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -371,7 +369,6 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
"wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -490,7 +487,6 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -583,13 +579,12 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
assert.Equal(t, expectedDetail, detail)
}
@@ -609,7 +604,6 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled

View File

@@ -9,8 +9,7 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
### Go (top-level package `main`)
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch``app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
- `tray.go``Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes``fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState``onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
- `tray_linux.go``init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
- `tray_linux.go``init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
- `signal_unix.go` / `signal_windows.go``listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
@@ -37,9 +36,9 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `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, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
@@ -94,13 +93,10 @@ The main window is created up front in `main.go`. Auxiliary windows are created
- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/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** (`ProfilesTab.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 (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.)
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/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.
- **SessionExpiration** (`/#/dialog/session-expiration?seconds=<n>`) — opened by `WindowManager.OpenSessionExpiration(seconds)`. 460×380, fixed size, `AlwaysOnTop: true`. The React-side buttons close the window via `WindowManager.CloseSessionExpiration` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Triggered by the tray today: `tray_session.go openSessionExpiration` fires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, and `openSessionExtendFlow` opens it on tray-row click seeded with the live remaining time. **Multi-monitor aware** — targets the display the OS cursor is currently on via `WindowManager.getScreenBasedOnCursorPosition`, which queries the native cursor location per-OS through `getCursorPosition` (`services/cursor_{darwin,windows,linux,other}.go`): `NSEvent.mouseLocation` flipped against the primary's frame height on macOS, `w32.GetCursorPos` + ScreenManager `PhysicalToDipPoint` on Windows, X11 `XQueryPointer` on Linux. The X11 query covers Wayland sessions too via XWayland, which ships by default on every supported Linux target. **Verified distro coverage**: Windows, macOS, Ubuntu 22.04 + 24.04 (GNOME-Wayland default + XWayland), Fedora 40 (GNOME-Wayland + XWayland), Debian 12 (GNOME default + XWayland), Arch Linux (any DE/compositor + XWayland), Linux Mint (Cinnamon-Xorg → Xorg direct), GNOME (Xorg + Wayland), Fluxbox (Xorg, exercised by the xembed-tray test path). Falls back gracefully (no panic, no error) to the main window's screen, then the OS default, when the cursor can't be resolved (headless / no DISPLAY / pure-Wayland-without-XWayland). Both first-create and re-show go through a single helper, `WindowManager.centerOnCursorScreen`: synchronous SetPosition first (covers full desktops and re-show with a still-alive GTK surface), then on minimal WMs (`recenterOnShow` — the Fluxbox/XEmbed-tray path) the same ~1s realize-detection retry loop `centerWhenReady` uses, because Wails' Linux SetPosition silently no-ops against a nil GdkSurface and Fluxbox would otherwise leave the window on the primary monitor.
- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/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 no triggers wired — daemon-status integration is a follow-up.
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
@@ -117,25 +113,25 @@ Package layout:
- `client/ui/preferences/``Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpiration.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpired.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
## Linux tray support
The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`.
**Legacy `-tags gtk3` build:** Wails v3 defaults to GTK4/WebKitGTK 6.0; the legacy GTK3/WebKit2GTK 4.1 path (`-tags gtk3`, for Ubuntu 22.04 / Debian 12 / RHEL 9 / Fedora ≤39, removed upstream in Wails v3.1) is shipped as a second `netbird-ui` package built via `EXTRA_TAGS=gtk3` / a separate goreleaser lane. `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` are GTK4-only (`//go:build … && !gtk3`); on gtk3 builds `xembed_host_gtk3_linux.go` stubs them out (`xembedTrayAvailable()` → false), so the minimal-WM XEmbed fallback is **absent on gtk3** (tray still works on SNI-capable desktops). Keep the C files' `//go:build` constraints in sync with the Go file.
## Wails Dialogs (frontend, `@wailsio/runtime`)
The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed.
API surface — `Dialogs.Info` / `Warning` / `Error` / `Question` / `OpenFile` / `SaveFile`, options shape, per-OS behaviour, and the Go-side frameless-window pattern — lives in `WAILS-DIALOGS.md` (sibling). The conventions for **when** to use a native dialog vs inline UI are in the "Conventions" section below.
## Conventions in this codebase
### Errors → custom Error window
### Errors → native dialogs
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
User-actionable operation failures (config save, profile switch, debug bundle, update, etc.) surface via `Dialogs.Error` with an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong". The dialog itself already says "Error" visually.
**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
Confirmations use `Dialogs.Warning` with explicit `Buttons`. The promise resolves with the **button Label string**, not an index — pin the label into a variable before comparing (especially with i18n, where labels translate). Full API in `WAILS-DIALOGS.md`.
**Skip native dialogs** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no native dialog needed there.
### OS notifications
@@ -151,8 +147,6 @@ The tray uses Wails' built-in `notifications` service. One `notifications.Notifi
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
Both `windows:build` and `windows:build:console` (the latter outputs `bin/netbird-ui-console.exe` linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour `DEV=true`, which drops the `-tags production` flag. The `production` tag is what disables the WebKit/WebView2 DevTools inspector — so `DEV=true` is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with `CGO_ENABLED=1 task windows:build:console DEV=true`.
## Useful references
- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern.
- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details.

View File

@@ -6,7 +6,3 @@ Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator e
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
## Legacy GTK3 build (`-tags gtk3`)
The XEmbed host (`xembed_host_linux.go` + `xembed_tray_linux.{c,h}`) hard-links GTK4 and uses GTK4-only popup-menu APIs (`GdkSurface`, `GtkEventControllerFocus`, `gtk_window_set_child`, `gdk_display_get_monitors``GListModel`, …), so it cannot compile against GTK3. On the legacy `-tags gtk3` build those files are excluded (`//go:build … && !gtk3`) and `xembed_host_gtk3_linux.go` provides a pure-Go stub where `xembedTrayAvailable()` returns false. The watcher probe then exits immediately, so the in-process XEmbed fallback is **absent on GTK3 builds** — the tray works only where the desktop ships its own `StatusNotifierWatcher` (KDE, GNOME+AppIndicator, Cinnamon/xapp, XFCE), not on minimal WMs. Rather than port the ~150-line C menu layer to GTK3 we accept this gap; `-tags gtk3` is removed upstream in Wails v3.1.

View File

@@ -11,22 +11,8 @@ WebView.
- `task`: `go install github.com/go-task/task/v3/cmd/task@latest`
- A running NetBird daemon (default: `unix:///var/run/netbird.sock`,
Windows `tcp://127.0.0.1:41731`)
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
### Legacy GTK3 build
Wails v3 builds on GTK4 / WebKitGTK 6.0 by default. Distros that don't ship
WebKitGTK 6.0 yet (Ubuntu 22.04, Debian 12, RHEL 9, Fedora ≤ 39) need the
legacy GTK3 / WebKit2GTK 4.1 build, produced with `-tags gtk3` (e.g.
`task build EXTRA_TAGS=gtk3`). It needs `libgtk-3-dev` + `libwebkit2gtk-4.1-dev`
instead of the GTK4 libs above. `-tags gtk3` is removed upstream in Wails v3.1.
> **Tray limitation:** the GTK3 build drops the in-process XEmbed
> `StatusNotifierWatcher` (its menu layer is GTK4-only — see
> [`LINUX-TRAY.md`](LINUX-TRAY.md) and `xembed_host_gtk3_linux.go`). The tray
> still works on desktops that ship their own watcher (KDE, GNOME+AppIndicator,
> Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/dwm)
> is unavailable on GTK3 packages.
- Linux only: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`,
`libayatana-appindicator3-dev`
## Develop without rebuilding

View File

@@ -19,11 +19,10 @@ import (
// side) so UI-side consumers don't have to import the daemon-internal
// package directly.
const (
MetaWarning = sessionwatch.MetaSessionWarning
MetaFinal = sessionwatch.MetaSessionFinal
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected
MetaWarning = sessionwatch.MetaSessionWarning
MetaFinal = sessionwatch.MetaSessionFinal
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
)
// Warning is the typed payload emitted on the session-warning Wails

View File

@@ -5,5 +5,4 @@ Icon=netbird
Type=Application
Terminal=false
Categories=Utility;
Keywords=netbird;
StartupWMClass=org.wails.netbird
Keywords=netbird;

View File

@@ -24,24 +24,24 @@ contents:
- src: "./build/linux/netbird-ui.desktop"
dst: "/usr/share/applications/netbird-ui.desktop"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
depends:
- libgtk-4-1
- libwebkitgtk-6.0-4
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Distribution-specific overrides for different package formats
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
rpm:
depends:
- gtk4
- webkitgtk6.0
# Arch Linux packages
- gtk3
- webkit2gtk4.1
# Arch Linux packages (WebKit 4.1)
archlinux:
depends:
- gtk4
- webkitgtk-6.0
- gtk3
- webkit2gtk-4.1
# scripts section to ensure desktop database is updated after install
scripts:

View File

@@ -41,11 +41,6 @@ tasks:
Cross-compile from Linux works the same way:
CGO_ENABLED=1 task windows:build:console
Pass DEV=true to drop the `production` build tag so the WebKit/WebView2
DevTools inspector (right-click → Inspect, or F12) stays enabled and the
frontend JS console is reachable — same DEV handling as windows:build:
CGO_ENABLED=1 task windows:build:console DEV=true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
@@ -66,11 +61,9 @@ tasks:
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
# Identical to build:native's flags (including DEV handling) except no
# -H windowsgui, so the binary attaches to the launching console. With
# DEV=true the `production` tag is dropped, keeping the WebKit/WebView2
# DevTools inspector enabled so the frontend JS console is reachable.
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
# Identical to build:native's flags except no -H windowsgui, so the
# binary attaches to the launching console.
BUILD_FLAGS: '-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"'
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
env:

View File

@@ -25,15 +25,14 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route |
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
| `/dialog/install-progress` | `UpdateInProgressDialog` (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). |
| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpiration(seconds)`, always-on-top, mm:ss countdown via `?seconds=`). Drives both the soon-to-expire warning and (when seconds elapse to zero) the expired state. |
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
| `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
In `app.tsx` the dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
Page-specific chrome lives next to the page, not in the layout:
- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only:
@@ -52,25 +51,23 @@ Page-specific chrome lives next to the page, not in the layout:
- `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`.
- `modules/settings/``SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`.
- `modules/login/``LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
- `modules/session/``SessionExpirationDialog.tsx` (session expiration warning + expired state).
- `modules/session/``SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
- `modules/auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
- `modules/error/``ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`.
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
- `modules/profiles/``ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
- `components/` — presentational primitives, no domain coupling. Grouped by family:
- `components/buttons/``Button`, `IconButton`.
- `components/inputs/``Input`, `SearchInput`.
- `components/dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
- `components/dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
- `components/switches/``SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
- `components/typography/``Label`, `HelpText`.
- `components/empty-state/``EmptyState`, `NoResults`, `NotConnectedState`.
- Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives).
- `layouts/``AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`.
- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`).
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller.
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`.
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
## Wails event bus
@@ -157,7 +154,7 @@ Compare against the variable, never against an English literal.
**Language picker.** `src/components/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to.
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpiration/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
**What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated.
## Login flow (`startLogin` in `ConnectionStatusSwitch.tsx`)
@@ -181,11 +178,9 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
## Dialogs convention
**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow.
**Always go through `src/lib/dialogs.ts`**`errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time.
For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx``const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`.
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
## Tailwind tokens
@@ -201,7 +196,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
## Things in flight (don't be surprised by)
- **`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 `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table.
- **`modules/session/SessionExpirationDialog.tsx`** is the always-on-top auxiliary window for the SSO expiration warning. Triggered by the tray (`tray_session.go openSessionExpiration` at T-FinalWarningLead; `openSessionExtendFlow` from the "Expires in …" tray row). Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. When the countdown hits zero the same component flips to the "expired" copy (`sessionExpiration.expired*` keys).
- **`modules/session/SessionExpiredDialog.tsx`** and **`modules/session/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. No triggers wired today — 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})`.
## Wails Go API reference

View File

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

View File

@@ -2,10 +2,9 @@ import React from "react";
import ReactDOM from "react-dom/client";
import "./globals.css";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { MainPage } from "@/modules/main/MainPage.tsx";
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
@@ -38,9 +37,8 @@ Promise.all([
<Route path="dialog">
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
<Route path="install-progress" element={<UpdateInProgressDialog />} />
<Route path="session-expiration" element={<SessionExpirationDialog />} />
<Route path="welcome" element={<WelcomeDialog />} />
<Route path="error" element={<ErrorDialog />} />
<Route path="session-expired" element={<SessionExpiredDialog />} />
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<MainPage />} />

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 512 512">
<path fill="#ffce00" d="M0 341.3h512V512H0z"/>
<path fill="#000001" d="M0 0h512v170.7H0z"/>
<path fill="red" d="M0 170.7h512v170.6H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 512 512">
<path fill="#012169" d="M0 0h512v512H0z"/>
<path fill="#FFF" d="M512 0v64L322 256l190 187v69h-67L254 324 68 512H0v-68l186-187L0 74V0h62l192 188L440 0z"/>
<path fill="#C8102E" d="m184 324 11 34L42 512H0v-3zm124-12 54 8 150 147v45zM512 0 320 196l-4-44L466 0zM0 1l193 189-59-8L0 49z"/>
<path fill="#FFF" d="M176 0v512h160V0zM0 176v160h512V176z"/>
<path fill="#C8102E" d="M0 208v96h512v-96zM208 0v512h96V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-hu" viewBox="0 0 512 512">
<g fill-rule="evenodd">
<path fill="#fff" d="M512 512H0V0h512z"/>
<path fill="#388d00" d="M512 512H0V341.3h512z"/>
<path fill="#d43516" d="M512 170.8H0V.1h512z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -2,15 +2,6 @@ import { useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
// Static map — Tailwind JIT only picks up literal class names, so dynamic
// template strings would be invisible to it.
const VARIANT_HOVER = {
default: "group-hover/copy:[&_*]:text-nb-gray-300",
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
} as const;
type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
type CopyToClipboardProps = {
children: ReactNode;
message?: string;
@@ -19,11 +10,6 @@ type CopyToClipboardProps = {
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
// variant picks the text colour the wrapped content fades into on hover.
// - "default" → nb-gray-300 (peer-details, settings, etc.)
// - "bright" → nb-gray-200 (deeper-surface contexts like the main
// connection card where text needs more lift)
variant?: CopyToClipboardVariant;
};
export const CopyToClipboard = ({
@@ -34,7 +20,6 @@ export const CopyToClipboard = ({
className,
iconClassName,
alwaysShowIcon = false,
variant = "default",
}: CopyToClipboardProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
@@ -58,22 +43,11 @@ export const CopyToClipboard = ({
ref={wrapperRef}
onClick={handleClick}
className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable",
"inline-flex gap-2 items-center group/copy cursor-pointer wails-no-draggable",
className,
)}
>
<span
className={cn(
"relative truncate min-w-0",
// [&_*] is Tailwind's arbitrary descendant variant: & is
// this element, _ is the CSS descendant combinator, * is
// every descendant. The generated selector has higher
// specificity than a child's own text-nb-gray-* class, so
// the hover colour wins the cascade.
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
>
<span className={cn("relative truncate min-w-0")}>
{children}
<span
className={

View File

@@ -4,7 +4,7 @@ import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
@@ -13,17 +13,43 @@ import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
// Intentionally no flag icons here: flags represent countries, not
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
// etc.). Each label shows the endonym followed by the englishName in
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
// the trigger and the dropdown rows.
// See: https://www.flagsarenotlanguages.com/blog/
// Flags live alongside the rest of the SVG flag library under
// assets/flags/1x1 and are filename-matched to the language code
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
// build time; the JS bundle only holds URL refs, not the SVG bytes.
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
eager: true,
import: "default",
query: "?url",
});
const labelFor = (lang: Language): string =>
lang.englishName && lang.englishName !== lang.displayName
? `${lang.displayName} (${lang.englishName})`
: lang.displayName;
const flagByCode: Record<string, string> = {};
for (const path in FLAG_URLS) {
const match = path.match(/1x1\/([^/]+)\.svg$/);
if (match) flagByCode[match[1]] = FLAG_URLS[path];
}
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
function Flag({ code, label }: { code: string; label: string }) {
const src = flagFor(code);
if (!src) {
return (
<span
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
aria-hidden
/>
);
}
return (
<img
src={src}
alt={label}
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
draggable={false}
/>
);
}
export function LanguagePicker() {
const { t, i18n } = useTranslation();
@@ -95,9 +121,9 @@ export function LanguagePicker() {
"disabled:opacity-50",
)}
>
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
{current && <Flag code={current.code} label={current.displayName} />}
<span className={"truncate flex-1 text-left"}>
{current ? labelFor(current) : "—"}
{current?.displayName ?? "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
</button>
@@ -167,8 +193,12 @@ export function LanguagePicker() {
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
)}
>
<span className={"flex-1 min-w-0 truncate"}>
{labelFor(lang)}
<Flag
code={lang.code}
label={lang.displayName}
/>
<span className={"flex-1 truncate"}>
{lang.displayName}
</span>
<span
className={

View File

@@ -7,28 +7,21 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
// fullWidth stretches the segmented control to fill its container —
// the SettingsGeneral row uses the default (shrink-to-content) layout,
// the welcome dialog asks for the wide variant so the picker spans the
// narrow dialog width.
fullWidth?: boolean;
};
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
const { t, i18n } = useTranslation();
const itemClass = fullWidth ? "flex-1" : undefined;
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
className={fullWidth ? "w-full" : undefined}
>
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
<SwitchItem value={ManagementMode.Cloud}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
<SwitchItem value={ManagementMode.SelfHosted}>
{t("settings.general.management.selfHosted")}
</SwitchItem>
</SwitchItemGroup>

View File

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

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
@@ -9,15 +9,8 @@ type Props = {
align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number;
sideOffset?: number;
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
// Overrides the default tooltip-content chrome (background, padding,
// border, radius). Use when a richer body needs popover-style layout.
contentClassName?: string;
// Ms to wait after pointer-leave before closing. Lets the user cross
// a gap between trigger and content without the tooltip snapping shut.
closeDelay?: number;
};
export const Tooltip = ({
@@ -27,35 +20,14 @@ export const Tooltip = ({
align = "center",
delayDuration = 200,
sideOffset = 6,
alignOffset = 0,
interactive = false,
keepOpenOnClick = true,
contentClassName,
closeDelay = 0,
}: Props) => {
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
if (closeDelay <= 0) {
setOpen(false);
return;
}
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
};
useEffect(() => () => cancelClose(), []);
const handleOpenChange = (next: boolean) => {
if (!next && keepOpenOnClick && hoveringRef.current) return;
if (next) cancelClose();
setOpen(next);
};
@@ -69,11 +41,10 @@ export const Tooltip = ({
asChild
onPointerEnter={() => {
hoveringRef.current = true;
cancelClose();
}}
onPointerLeave={() => {
hoveringRef.current = false;
scheduleClose();
setOpen(false);
}}
>
{children}
@@ -83,19 +54,15 @@ export const Tooltip = ({
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={
interactive ? undefined : (e) => e.preventDefault()
}
className={cn(
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
"text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
!interactive && "pointer-events-none",
contentClassName ??
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
)}
>
{content}

View File

@@ -1,42 +0,0 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Tooltip } from "@/components/Tooltip";
type Props = {
text: string;
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
};
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
// the "tooltip on hover even though everything fits" annoyance. The caller
// supplies the wrapper styling (font, max-width, etc.) via className — this
// component only owns the truncate + measure + tooltip behavior.
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
}: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [text]);
const span = (
<span ref={ref} className={className}>
{text}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
{span}
</Tooltip>
);
};

View File

@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
{...props}
/>
);

View File

@@ -1,5 +1,5 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, Copy, Loader2 } from "lucide-react";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
import { cn } from "@/lib/cn";
@@ -10,16 +10,12 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
// When true, the content is replaced by a centered spinner while keeping
// the button's rendered width/height (the content stays in the layout,
// just hidden). Also disables the button.
loading?: boolean;
}
const buttonVariants = cva(
[
"relative",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none cursor-default",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none",
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
],
@@ -97,7 +93,7 @@ const buttonVariants = cva(
},
size: {
xs: "text-xs py-2.5 px-3.5",
xs2: "text-[0.78rem] py-[1.1rem] px-4 leading-[0]",
xs2: "text-[0.78rem] py-2 px-4",
sm: "text-sm py-[9px] px-4",
md: "py-[9px] px-4",
lg: "text-lg py-[9px] px-4",
@@ -128,7 +124,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
onClick,
disabled,
copy,
loading = false,
...props
},
ref,
@@ -139,7 +134,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
<button
ref={ref}
type={type}
disabled={disabled || loading}
disabled={disabled}
className={cn(
buttonVariants({
variant,
@@ -164,16 +159,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
}}
{...props}
>
{loading && (
<span className={"absolute inset-0 flex items-center justify-center"}>
<Loader2 size={iconSize} className={"animate-spin"} />
</span>
)}
<span className={cn("contents", loading && "invisible")}>
{copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
</span>
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
</button>
);
});

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
// ConfirmDialog is the shared layout wrapper used by dialog-style window
// surfaces (SessionExpiration, …). Purely a layout
// surfaces (SessionExpired, SessionAboutToExpire, …). Purely a layout
// primitive — callers compose the contents (SquareIcon, DialogHeading,
// DialogDescription, DialogActions) so each dialog can tweak its own
// internal structure without growing the ConfirmDialog API.
@@ -24,7 +24,7 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
<div
ref={ref}
className={cn(
"flex flex-col items-center gap-5 text-center px-8 pt-6 pb-7",
"flex flex-col items-center gap-5 text-center px-8 py-6",
isMacOS() && "pt-10",
)}
>

View File

@@ -1,110 +0,0 @@
import { ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Button } from "@/components/buttons/Button";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to a native confirm dialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
// single instance at the provider level. Render ConfirmModal yourself only
// when you need bespoke control over its open/busy lifecycle.
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
/** Confirm button label. */
confirmLabel: string;
/** Cancel button label; defaults to the shared "Cancel" string. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
/** Disable the buttons (and ignore dismiss) while an action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export const ConfirmModal = ({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
// Retain the last shown content so it stays rendered through Radix's
// close animation instead of blanking out the instant the caller clears
// its state on close.
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
cancelLabel: string;
};
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const resolvedCancel = cancelLabel ?? t("common.cancel");
useEffect(() => {
if (open) {
setSnapshot({ title, description, confirmLabel, cancelLabel: resolvedCancel, danger });
}
}, [open, title, description, confirmLabel, resolvedCancel, danger]);
const view = open
? { title, description, confirmLabel, cancelLabel: resolvedCancel, danger }
: snapshot;
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
}}
>
<Dialog.Content
maxWidthClass="max-w-sm"
showClose={false}
className="py-5"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{view && (
<div className="flex flex-col gap-5 px-5">
<div className="flex flex-col gap-1 pl-1">
<DialogHeading align={"left"}>{view.title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
{view.description}
</DialogDescription>
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button
variant={"secondary"}
size={"xs2"}
disabled={busy}
onClick={onCancel}
>
{view.cancelLabel}
</Button>
<Button
autoFocus
variant={view.danger ? "danger" : "primary"}
size={"xs2"}
disabled={busy}
onClick={onConfirm}
>
{view.confirmLabel}
</Button>
</DialogActions>
</div>
)}
</Dialog.Content>
</Dialog.Root>
);
};

View File

@@ -2,7 +2,6 @@ import { forwardRef, ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
@@ -16,7 +15,7 @@ const Overlay = forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/60",
"bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out",
@@ -37,7 +36,6 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{ className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
ref,
) {
const { t } = useTranslation();
return (
<DialogPrimitive.Portal>
<Overlay>
@@ -69,7 +67,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
aria-label={t("common.close")}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>

View File

@@ -3,32 +3,11 @@ import { cn } from "@/lib/cn";
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogDescriptionProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
// w-full for the same reason DialogHeading carries it — see the
// comment there. The default text-center remains visually identical
// to before; left/right alignment now anchors to the dialog content
// edge instead of collapsing to no-op on a content-width box.
<p
className={cn(
"w-full text-sm text-nb-gray-300 select-none",
alignClass[align],
className,
)}
>
{children}
</p>
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
<p className={cn("text-sm text-nb-gray-300 select-none", className)}>{children}</p>
);

View File

@@ -4,34 +4,13 @@ import { cn } from "@/lib/cn";
// DialogHeading is the title text used inside ConfirmDialog (and any other
// dialog-style surface with the same shape). Pair with DialogDescription
// for the standard title/description stack.
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
// w-full so the alignClass actually has a box to anchor against.
// The wrapping <p> defaulted to content width inside a flex column,
// which made `text-left` a no-op (nothing to push the text away
// from). Stretching the element is invisible for the default
// text-center case (center of content == center of box) and lets
// text-left/right line up with the dialog's content edge.
<p
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
alignClass[align],
className,
)}
>
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
<p className={cn("text-base font-semibold text-nb-gray-50 select-none", className)}>
{children}
</p>
);

View File

@@ -1,5 +1,7 @@
import { ComponentType } from "react";
import { LucideProps } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { ExternalLinkIcon, LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "@/components/SquareIcon";
@@ -7,20 +9,54 @@ type Props = {
icon: ComponentType<LucideProps>;
title: string;
description?: string;
learnMoreUrl?: string;
learnMoreTopic?: string;
className?: string;
};
export const EmptyState = ({ icon, title, description, className }: Props) => {
const openUrl = (url: string) => {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
};
export const EmptyState = ({
icon,
title,
description,
learnMoreUrl,
learnMoreTopic,
className,
}: Props) => {
const { t } = useTranslation();
return (
<div className={cn("py-12 text-center", className)}>
<div
className={
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]"
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
}
>
<SquareIcon icon={icon} className={"mb-3"} />
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
<p className={"text-base font-medium text-nb-gray-200 mb-1"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-350"}>{description}</p>}
{learnMoreUrl && learnMoreTopic && (
<p className={"text-sm text-nb-gray-350"}>
{t("common.learnMoreAbout")}{" "}
<a
href={learnMoreUrl}
onClick={(e) => {
e.preventDefault();
openUrl(learnMoreUrl);
}}
className={cn(
"text-netbird hover:underline underline-offset-4",
"cursor-pointer wails-no-draggable",
"inline-flex items-center gap-1",
)}
>
{learnMoreTopic}
<ExternalLinkIcon size={12} className={"shrink-0"} />
</a>
</p>
)}
</div>
</div>
);

View File

@@ -16,7 +16,7 @@ export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) =>
icon={icon}
title={title ?? t("common.noResults.title")}
description={description ?? t("common.noResults.description")}
className={"relative -top-[3.8rem]"}
className={"relative -top-3.5"}
/>
);
};

View File

@@ -5,7 +5,11 @@ import { EmptyState } from "./EmptyState";
export const NotConnectedState = () => {
const { t } = useTranslation();
return (
<div className={"relative w-full top-[3rem]"}>
<div
className={
"h-full min-h-[260px] flex-1 flex items-center justify-center px-6 pb-20 top-1 relative"
}
>
<EmptyState
icon={GlobeOffIcon}
title={t("notConnected.title")}

View File

@@ -1,7 +1,6 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/Label";
@@ -14,10 +13,6 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
// e.g. for "couldn't reach this server" where the value is syntactically
// fine and the user may still proceed. `error` takes precedence.
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
@@ -38,10 +33,6 @@ const inputVariants = cva("", {
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
],
warning: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-orange-400 text-orange-400",
"ring-offset-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10 focus-visible:ring-orange-400/10",
],
},
prefixSuffixVariant: {
default: [
@@ -62,7 +53,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
icon,
maxWidthClass = "",
error,
warning,
variant = "default",
prefixClassName,
showPasswordToggle = false,
@@ -72,7 +62,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
},
ref,
) {
const { t } = useTranslation();
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
const isPasswordType = type === "password";
@@ -114,7 +103,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type="button"
onClick={() => setShowPassword((s) => !s)}
className="hover:text-white transition-all pointer-events-auto"
aria-label={t("common.togglePasswordVisibility")}
aria-label="Toggle password visibility"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
@@ -137,7 +126,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type="button"
onClick={onCopy}
className="hover:text-white transition-all pointer-events-auto"
aria-label={t("common.copy")}
aria-label="Copy"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
@@ -185,7 +174,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{...props}
className={cn(
inputVariants({
variant: error ? "error" : warning ? "warning" : variant,
variant: error ? "error" : variant,
}),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
@@ -228,7 +217,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
aria-label="Increase"
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
@@ -237,7 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
aria-label="Decrease"
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
@@ -249,14 +238,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</div>
)}
</div>
{(error || warning) && (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
{error && (
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
{error}
</span>
)}
</div>

View File

@@ -1,74 +0,0 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of a native confirmation dialog. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.
//
// const confirm = useConfirm();
// if (await confirm({ title, description, confirmLabel })) { …do it… }
//
// Mounted once (outermost in AppLayout) so it's available in every in-window
// route across both the main and settings windows.
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
};
type DialogContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
// Resolve the pending promise and start the close animation. The options
// stay in state so ConfirmModal still has content to render while it
// animates out.
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
return (
<DialogContext.Provider value={{ confirm }}>
{children}
<ConfirmModal
open={open}
title={options?.title ?? ""}
description={options?.description ?? ""}
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
/>
</DialogContext.Provider>
);
}
export const useConfirm = () => {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
return ctx.confirm;
};

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
export type NavSection = "peers" | "networks" | "exitNode";
type NavSectionContextValue = {
section: NavSection;

View File

@@ -13,7 +13,7 @@ import i18next from "@/lib/i18n";
// measurements (content changes after mount) only adjust the size.
//
// Re-measures via ResizeObserver so adding/removing content (e.g. the
// SessionExpiration title swapping at countdown zero) keeps the chrome
// SessionAboutToExpire title swapping at countdown zero) keeps the chrome
// tight to the content with no scrollbar.
//
// Also re-measures on i18next `languageChanged`. The ResizeObserver in
@@ -22,16 +22,7 @@ import i18next from "@/lib/i18n";
// the observer can settle on a stale size before React's commit and the
// font's glyph metrics finish updating. An explicit double-rAF after the
// language flip guarantees the final layout is the one we measure.
//
// `ready` (default true) gates Window.SetSize + Window.Show. Pass false
// while the caller is still resolving its initial content (e.g. waiting
// on an async probe) so the window stays Hidden instead of briefly
// rendering placeholder padding at the wrong size — Linux/GNOME in
// particular paints whatever the frame ends up at, and a transient
// half-height frame can leak through. Flip ready=true once the real
// content is in the DOM; the effect re-runs, measures the final size,
// and shows the window.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
const el = ref.current;
@@ -40,7 +31,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
let raf1 = 0;
let raf2 = 0;
const apply = () => {
if (!ready) return;
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
// Wails Window.SetSize takes the *frame* size on every platform
@@ -89,6 +79,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
cancelAnimationFrame(raf2);
i18next.off("languageChanged", scheduleApply);
};
}, [width, ready]);
}, [width]);
return ref;
}

View File

@@ -1,14 +1,23 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { warningDialog } from "@/lib/dialogs.ts";
import i18next from "@/lib/i18n";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useConfirm } from "@/contexts/DialogContext.tsx";
export enum ManagementMode {
Cloud = "cloud",
SelfHosted = "selfhosted",
}
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
// reachability is checked separately via checkManagementUrlReachable.
export const URL_PATTERN = new RegExp(
function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
const URL_PATTERN = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
@@ -18,67 +27,17 @@ export const URL_PATTERN = new RegExp(
"i",
);
// normalizeManagementUrl prefixes an https:// scheme when the user omits
// it. Empty input stays empty.
export function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
// touch the network.
export function isValidManagementUrl(input: string): boolean {
function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
// isCloudManagementUrl reports whether the stored URL is the NetBird
// Cloud default (or an empty/unset URL, which the daemon also treats as
// cloud-defaulting on first boot).
export function isCloudManagementUrl(url: string): boolean {
if (!url || url.trim() === "") return true;
return url === CLOUD_MANAGEMENT_URL;
}
// checkManagementUrlReachable does a best-effort no-cors GET against the
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
// as unreachable. Self-hosted deployments behind internal-only DNS or
// with self-signed certs may return false positives — callers should
// surface this as a soft warning, not a hard block.
export async function checkManagementUrlReachable(
url: string,
timeoutMs: number = 5000,
): Promise<boolean> {
const target = normalizeManagementUrl(url);
if (!target) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
return true;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
export enum ManagementMode {
Cloud = "cloud",
SelfHosted = "selfhosted",
}
function modeFromUrl(url: string): ManagementMode {
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
}
export function useManagementUrl() {
const { t } = useTranslation();
const confirm = useConfirm();
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
@@ -86,11 +45,11 @@ export function useManagementUrl() {
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
// Self-hosted reachability soft-check, mirrored from the onboarding /
// profile-creation flows: a failed probe is a non-blocking orange warning,
// and a second Save with the same URL goes through regardless.
const [checking, setChecking] = useState(false);
const [unreachable, setUnreachable] = useState(false);
// Guard against double-showing the cloud-switch confirmation when the
// user toggles the segmented control multiple times before the prior
// Dialogs.Warning promise resolves. Without it each click queues a
// fresh native dialog and the user sees them stack up.
const switchConfirmOpenRef = useRef(false);
useEffect(() => {
setModeState(modeFromUrl(config.managementUrl));
@@ -99,27 +58,34 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
// Clear the stale warning whenever the target changes.
useEffect(() => {
setUnreachable(false);
}, [url, mode]);
const setMode = async (next: ManagementMode) => {
const setMode = (next: ManagementMode) => {
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm via the in-app modal before applying.
const ok = await confirm({
title: t("settings.general.management.switchCloudTitle"),
description: t("settings.general.management.switchCloudMessage"),
confirmLabel: t("settings.general.management.switchCloudConfirm"),
});
if (!ok) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
// reconnect/re-login. Confirm before applying.
if (switchConfirmOpenRef.current) return;
switchConfirmOpenRef.current = true;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
void warningDialog({
Title: i18next.t("settings.general.management.switchCloudTitle"),
Message: i18next.t("settings.general.management.switchCloudMessage"),
Buttons: [
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
{ Label: confirmLabel },
],
})
.then((result) => {
if (result !== confirmLabel) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
})
.finally(() => {
switchConfirmOpenRef.current = false;
});
return;
}
setModeState(next);
@@ -135,22 +101,7 @@ export function useManagementUrl() {
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = async () => {
// Self-hosted: probe the server first. A failed probe surfaces a soft
// warning and bails; a second Save (unreachable already set) skips the
// re-check and saves anyway, so the user can override a false negative.
if (mode === ManagementMode.SelfHosted && !unreachable) {
setChecking(true);
const reachable = await checkManagementUrlReachable(targetUrl);
setChecking(false);
if (!reachable) {
setUnreachable(true);
return;
}
}
await saveField("managementUrl", targetUrl);
setUnreachable(false);
};
const save = () => saveField("managementUrl", targetUrl);
return {
mode,
@@ -161,7 +112,5 @@ export function useManagementUrl() {
showError,
canSave,
save,
checking,
unreachable,
};
}

View File

@@ -3,7 +3,6 @@ import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
import { StatusProvider } from "@/contexts/StatusContext.tsx";
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
// Shared shell for every in-window route (main + settings). Owns the daemon-
// availability gate (via StatusProvider) and the providers every page needs.
@@ -15,17 +14,15 @@ import { DialogProvider } from "@/contexts/DialogContext.tsx";
export const AppLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
<DialogProvider>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
</DialogProvider>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
</div>
);
};

View File

@@ -6,7 +6,6 @@ type Props = {
children: ReactNode;
overlay?: ReactNode;
overlayOpen?: boolean;
className?: string;
};
// iOS-style push transition: incoming pane slides in from the right while
@@ -17,14 +16,13 @@ const PANEL_TRANSITION = {
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
return (
<div
className={cn(
"wails-no-draggable relative m-5",
"wails-no-draggable relative m-4",
"bg-nb-gray-940 border border-nb-gray-920",
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
className,
)}
>
<motion.div

View File

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

View File

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

View File

@@ -33,14 +33,3 @@ export const formatRelative = (
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
// shortenDns drops the domain suffix off a DNS name, returning just the
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
// is operator-configurable so we keep everything before the first dot rather
// than matching against a known suffix. The full DNS name still lands on
// the clipboard via the copy helpers' explicit message prop.
export const shortenDns = (fqdn: string | undefined | null): string => {
if (!fqdn) return "";
const dot = fqdn.indexOf(".");
return dot === -1 ? fqdn : fqdn.slice(0, dot);
};

View File

@@ -29,21 +29,17 @@ for (const path in bundleModules) {
}
// detectBrowserLanguage walks navigator.language + navigator.languages
// and returns the first shipped bundle that matches. We try an exact
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
// null when nothing matches, so the caller can fall back to English.
// and returns the first base code ("de" from "de-DE") that has a shipped
// bundle. Returns null when none match, so the caller can fall back to
// English. We only ever match against the lowercased base — region tags
// don't have separate bundles today.
function detectBrowserLanguage(available: string[]): string | null {
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
(tag): tag is string => typeof tag === "string" && tag.length > 0,
);
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
for (const tag of tags) {
const lower = tag.toLowerCase();
const exact = byLower.get(lower);
if (exact) return exact;
const base = byLower.get(lower.split("-")[0]);
if (base) return base;
const base = tag.toLowerCase().split("-")[0];
if (available.includes(base)) return base;
}
return null;
}

View File

@@ -23,7 +23,6 @@ export const mockPeers: PeerStatus[] = [
// eyeballing layout at maximum density.
new PeerStatus({
ip: "100.64.0.1",
ipv6: "fd00:dead:beef::1",
pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=",
connStatus: "Connected",
connStatusUpdateUnix: SECONDS(4),

View File

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

View File

@@ -10,71 +10,45 @@ import { useProfile } from "@/contexts/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import { formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
// device code.
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
// The tray emits it from menu items so the React UI (which owns the SSO
// orchestration and the browser-login window) takes over.
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
const EVENT_TRIGGER_LOGIN = "trigger-login";
// loginInFlight is a module-level guard. SSO login involves multiple async
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
// call would race on the daemon's pending device code and on the popup
// window's singleton, leading to confusing UX. Calls past the first are
// dropped silently — the first invocation owns the flow until it settles.
let loginInFlight = false;
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// startLogin drives the daemon's SSO login end-to-end:
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
// 3. Race WaitSSOLogin vs the user clicking Cancel.
// 4. On success: Connection.Up.
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
// drops the abandoned device code (avoids an Idle blink on the tray).
//
// Errors that aren't user cancellations surface via errorDialog. Concurrent
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
// all exit paths so a stray popup doesn't outlive the flow.
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
// instant the flow itself is over (success, cancel, or error) — BEFORE the
// error dialog is shown. Every guard that gates re-arming the login path
// (the module-level loginInFlight here, and the caller's React-level
// loginGuard via onSettled) must be released at that point, never gated on
// the dialog.
//
// Why the dialog must be outside the guards: the native Windows MessageBox
// disables its parent for its whole lifetime, and the main window's
// WindowClosing hook hides instead of closing — the two race and the dialog
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
// release awaited the dialog, that guard would stay held for as long as the
// box is open (or forever if it hangs), and every later Connect / tray
// trigger-login would be silently dropped at the guard check until the
// client is restarted. That was the original "can't log in again until
// restart" bug.
async function startLogin(onSettled?: () => void): Promise<void> {
if (loginInFlight) {
// The caller's guard must still be released — it was set before this
// call. Without this the React-level loginGuard would wedge on a
// dropped concurrent invocation.
onSettled?.();
return;
}
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
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
// Dialogs.Error. Concurrent calls are dropped via the inFlight guard.
let loginInFlight = false;
async function startLogin(): Promise<void> {
if (loginInFlight) return;
loginInFlight = true;
let cancelled = false;
let offCancel: (() => void) | undefined;
let loginError: unknown;
try {
const result = await Connection.Login({
@@ -90,6 +64,10 @@ async function startLogin(onSettled?: () => void): Promise<void> {
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) {
// Open the in-app sign-in popup first; the dialog itself
// fires Connection.OpenURL after it's actually on screen
// (see WaitingForBrowserDialog) so the system browser
// doesn't land on top of a still-hidden NetBird window.
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
@@ -116,6 +94,12 @@ async function startLogin(onSettled?: () => void): Promise<void> {
}
if (cancelled) {
// Cancel the in-flight WaitSSOLogin gRPC instead of a heavy
// Down. The daemon ties the wait to this call's context
// (server.go WaitSSOLogin), so cancelling ends the wait and
// clears the abandoned OAuth flow — a fresh Login then starts
// a new device code, with no Idle blink on the tray. Swallow
// the cancellation rejection on the abandoned promise.
waitPromise.cancel?.();
void waitPromise.catch(() => {});
return;
@@ -125,48 +109,17 @@ async function startLogin(onSettled?: () => void): Promise<void> {
await Connection.Up({ profileName: "", username: "" });
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
if (!cancelled) loginError = e;
} finally {
offCancel?.();
// Release every guard before any UI work below — never gate re-arming
// the login path on a dialog that can hang. loginInFlight is ours;
// onSettled releases the caller's React-level loginGuard.
loginInFlight = false;
onSettled?.();
}
if (loginError !== undefined) {
if (cancelled) return;
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: formatErrorMessage(loginError),
Message: errorMessage(e),
});
} finally {
offCancel?.();
loginInFlight = false;
}
}
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
@@ -198,12 +151,7 @@ export const MainConnectionStatusSwitch = () => {
if (loginGuard.current) return;
loginGuard.current = true;
setAction("logging-in");
// Release the React-level guard via onSettled — fired the instant the
// flow ends, before startLogin's error dialog. Gating it on the full
// startLogin() promise would keep loginGuard wedged for the whole
// dialog lifetime, leaving the tray's trigger-login dropped at the
// guard check until the client is restarted.
void startLogin(() => {
void startLogin().finally(() => {
loginGuard.current = false;
setAction(null);
void refresh();
@@ -390,22 +338,12 @@ export const MainConnectionStatusSwitch = () => {
});
}
};
const show = connState === ConnectionState.Connected;
const showLocal = connState === ConnectionState.Connected;
const fqdn = status?.local.fqdn || "";
const ip = status?.local.ip || "";
const ipv6 = status?.local.ipv6 || "";
return (
<div
className={cn(
// Anchored from the top so the FQDN/IP lines below the toggle
// can grow into a popover-aware layout without shifting the
// toggle itself (justify-center would slide everything up
// when the IP line is hidden during Disconnected).
"flex flex-col h-full w-full items-center gap-4",
"relative top-[11.7rem]",
)}
>
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
<img
src={netbirdFullLogo}
alt={"NetBird"}
@@ -431,145 +369,29 @@ export const MainConnectionStatusSwitch = () => {
</h1>
<CopyToClipboard
message={fqdn}
variant={"bright"}
className={cn(
"min-h-[1em] transition-opacity duration-300 max-w-full",
"min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
show && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
className={
"block font-mono text-[0.8rem] leading-tight text-nb-gray-300 truncate max-w-[310px]"
}
/>
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
{fqdn || " "}
</span>
</CopyToClipboard>
<CopyToClipboard
message={ip}
className={cn(
"min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
</div>
</div>
);
};
// LocalIpLine shows the IPv4 inline (no copy icon). When the peer also has
// an IPv6, a tiny chevron sits next to the IPv4 and clicking the line opens
// a popover containing both v4 and v6, each independently click-to-copy.
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const [open, setOpen] = useState(false);
const hasV6 = !!ipv6;
if (!hasV6) {
return (
<CopyToClipboard
message={ip}
variant={"bright"}
className={cn(
"min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
{ip || " "}
</span>
</CopyToClipboard>
);
}
return (
<div
className={cn(
"min-h-[1em] transition-opacity duration-300 max-w-full",
"relative wails-no-draggable",
show && ip ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
className={cn(
// relative so the chevron can be absolutely
// positioned alongside without widening the trigger
// — keeps the IP text centred in its parent and
// lets the popover centre cleanly on it.
"group relative inline-flex items-center outline-none cursor-default",
"transition-colors",
)}
>
<span
className={cn(
"font-mono text-[0.8rem] leading-tight text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
>
{ip || " "}
</span>
<ChevronDownIcon
size={14}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
)}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"center"}
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 min-w-64 max-w-[280px] overflow-hidden",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-1 shadow-lg outline-none text-nb-gray-200",
"flex flex-col",
)}
>
<IpRow value={ip} />
<div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />
<IpRow value={ipv6} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
};
// IpRow is a single click-to-copy item inside the LocalIpLine popover. Mirrors
// the dropdown-menu item look (rounded, hover bg, transition) and shows a copy
// icon on the right that flips to a checkmark briefly after a successful copy.
const IpRow = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false);
const handleClick = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch {
// ignore
}
};
return (
<button
type={"button"}
onClick={handleClick}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"transition-colors outline-none cursor-default",
)}
>
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
<span className={"shrink-0 inline-flex items-center text-nb-gray-200"}>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>
);
};

View File

@@ -1,215 +0,0 @@
import { forwardRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronsUpDown, LucideProps, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const active = exitNodes.find((n) => n.selected) ?? null;
const isConnected = status?.status === "Connected";
const hasAny = exitNodes.length > 0;
const disabled = !isConnected || !hasAny;
const [open, setOpen] = useState(false);
const handleSelect = (next: string) => {
setOpen(false);
if (next === NONE_VALUE) {
if (active) void toggleExitNode(active.id, true);
return;
}
if (active && active.id === next) return;
void toggleExitNode(next, false);
};
const title = active ? active.id : t("exitNodes.card.title");
const description = !hasAny
? t("exitNodes.empty.title")
: active
? t("exitNodes.card.statusActive")
: t("exitNodes.card.statusInactive");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"}>
<ExitNodeTriggerCard
title={title}
description={description}
disabled={disabled}
active={!!active}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
side={"top"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: "var(--radix-popover-trigger-width)" }}
className={cn(
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
)}
>
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
<Command.List>
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
key={n.id}
id={n.id}
label={n.id}
isActive={active?.id === n.id}
onSelect={() => handleSelect(n.id)}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
title: string;
description: string;
active?: boolean;
};
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
function ExitNodeTriggerCard(
{ title, description, disabled, active = false, className, ...props },
ref,
) {
return (
<button
ref={ref}
type={"button"}
disabled={disabled}
className={cn(
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
disabled
? "opacity-60 cursor-not-allowed"
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
className,
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
active
? "bg-green-500/25 text-green-400"
: "bg-nb-gray-900 text-nb-gray-300",
)}
>
<ExitNodeIcon size={14} />
</div>
<div className={"min-w-0 flex-1"}>
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
<TruncatedText
text={description}
className={
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
}
/>
</div>
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
</button>
);
},
);
type NoneRowProps = {
isActive: boolean;
onSelect: () => void;
};
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
const { t } = useTranslation();
return (
<Command.Item
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
};
type ExitNodeRowProps = {
id: string;
label: string;
isActive: boolean;
onSelect: () => void;
};
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
<Command.Item
value={id}
onSelect={onSelect}
className={cn(
"flex gap-2 items-center px-2 py-2 pr-3",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
</Command.Item>
);
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);

View File

@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { isWindows } from "@/lib/platform.ts";
import {isWindows} from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -143,24 +143,21 @@ export const MainHeader = () => {
return (
<div
className={cn(
"shrink-0 cursor-default wails-draggable relative z-10",
"flex items-center h-12 top-3",
"shrink-0 cursor-default wails-draggable relative",
"flex items-center h-12 top-2.5",
)}
>
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
className={cn(
"grid grid-cols-3 items-center shrink-0",
isWindows() ? "w-[364px]" : "w-[380px]",
)}
>
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div />
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
{settingsSlot}
</div>
</div>
);
};

View File

@@ -1,5 +1,4 @@
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
import { MainHeader } from "@/modules/main/MainHeader.tsx";
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
@@ -10,6 +9,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
@@ -37,11 +37,8 @@ const MainBody = () => {
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
counts differently than macOS, so the visible content area lines up on both platforms.
See https://github.com/wailsapp/wails/issues/3260 */}
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<div className={cn("flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
<MainConnectionStatusSwitch />
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
<MainExitNodeSwitcher />
</div>
</div>
{isAdvanced && (
<NavSectionProvider>
@@ -59,11 +56,7 @@ const AdvancedAppRightPanel = () => {
const isConnected = status?.status === "Connected";
return (
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
>
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
<div
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
@@ -75,6 +68,7 @@ const AdvancedAppRightPanel = () => {
<div className={"flex-1 min-h-0 flex flex-col"}>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "exitNode" && <ExitNodes />}
</div>
</div>
{!isConnected && (

View File

@@ -1,6 +1,6 @@
import { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
@@ -28,6 +28,11 @@ export const Navigation = () => {
label: t("nav.resources.title"),
icon: Layers3Icon,
},
{
value: "exitNode",
label: t("nav.exitNode.title"),
icon: ExitNodeIcon,
},
];
return (
@@ -67,4 +72,12 @@ export const Navigation = () => {
);
};
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
<SquareArrowUpRight
{...props}
size={typeof size === "number" ? size - 2 : size}
className={cn("rotate-45", props.className)}
/>
);
export type { NavSection } from "@/contexts/NavSectionContext";

View File

@@ -0,0 +1,182 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as RadioGroup from "@radix-ui/react-radio-group";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { WaypointsIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { mockExitNodes, mockOr } from "@/lib/mock";
const NONE_VALUE = "__none__";
export const ExitNodes = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
const exitNodes = mockOr(realExitNodes, mockExitNodes);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
// Initial order: active-first, then by id. After that, positions are sticky
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
// the optimistic radio flip paints in place instead of the row jumping to
// the top.
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const byId = new Map(exitNodes.map((n) => [n.id, n]));
const kept = orderRef.current.filter((id) => byId.has(id));
const known = new Set(kept);
const fresh = exitNodes
.filter((n) => !known.has(n.id))
.sort((a, b) => {
if (a.selected !== b.selected) return a.selected ? -1 : 1;
return a.id.localeCompare(b.id);
})
.map((n) => n.id);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((id) => byId.get(id)!);
}, [exitNodes]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return ordered;
return ordered.filter((r) => r.id.toLowerCase().includes(q));
}, [ordered, search]);
if (isConnected && exitNodes.length === 0) {
return (
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={WaypointsIcon}
title={t("exitNodes.empty.title")}
description={t("exitNodes.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
learnMoreTopic={t("nav.exitNode.title")}
/>
</div>
);
}
return (
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};
type ExitNodesListProps = {
data: Network[];
onToggle: (id: string, selected: boolean) => void;
};
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
const { t } = useTranslation();
const active = data.find((n) => n.selected) ?? null;
const value = active?.id ?? NONE_VALUE;
const handleChange = (next: string) => {
if (next === value) return;
if (next === NONE_VALUE) {
if (active) onToggle(active.id, true);
return;
}
onToggle(next, false);
};
return (
<RadioGroup.Root
value={value}
onValueChange={handleChange}
className={"flex flex-col"}
>
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
{data.map((n) => (
<Row key={n.id} value={n.id} label={n.id} />
))}
</RadioGroup.Root>
);
};
type RowProps = {
value: string;
label: string;
first?: boolean;
};
const Row = ({ value, label, first }: RowProps) => (
<RadioGroup.Item
value={value}
className={cn(
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
first && "mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer outline-none text-left",
)}
>
<span
className={
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{label}
</span>
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border",
"border-nb-gray-700 bg-nb-gray-900",
"flex items-center justify-center",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator
className={"h-2 w-2 rounded-full bg-white"}
/>
</span>
</RadioGroup.Item>
);

View File

@@ -39,17 +39,23 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200",
"text-sm text-nb-gray-100",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable cursor-default",
"wails-no-draggable",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {

View File

@@ -1,12 +1,11 @@
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
import type { Network } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
@@ -77,7 +76,11 @@ export const Networks = () => {
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
const {
networkRoutes: realNetworkRoutes,
toggleNetwork,
setNetworksSelected,
} = useNetworks();
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<NetworkFilter>("all");
@@ -143,17 +146,27 @@ export const Networks = () => {
if (isConnected && networkRoutes.length === 0) {
return (
<EmptyState
icon={Layers3Icon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
/>
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={NetworkIcon}
title={t("networks.empty.title")}
description={t("networks.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
learnMoreTopic={t("nav.resources.title")}
/>
</div>
);
}
const selectedInView = filtered.filter((r) => r.selected).length;
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
const bulkLabel = allSelected
? t("networks.bulk.disableAll")
: t("networks.bulk.enableAll");
const onBulkClick = () => {
if (filtered.length === 0) return;
@@ -249,7 +262,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
key={n.id}
onClick={() => onToggle(n.id, n.selected)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}
@@ -258,21 +271,29 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={n.id}>
<TruncatedText
text={n.id}
<span
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
/>
>
{n.id}
</span>
</CopyToClipboard>
</div>
<Subtitle network={n} />
</div>
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
<div
className={"shrink-0 self-center"}
onClick={(e) => e.stopPropagation()}
>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
label={n.selected ? t("networks.selected") : t("networks.unselected")}
label={
n.selected
? t("networks.selected")
: t("networks.unselected")
}
/>
</div>
</li>
@@ -286,7 +307,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
return (
<div
className={cn(
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
)}
>
@@ -306,12 +327,9 @@ const Subtitle = ({ network }: { network: Network }) => {
return (
<div>
<CopyToClipboard message={network.range}>
<TruncatedText
text={network.range}
className={
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
}
/>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{network.range}
</span>
</CopyToClipboard>
</div>
);
@@ -321,63 +339,90 @@ const Subtitle = ({ network }: { network: Network }) => {
};
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
const span = (
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
{domain}
</span>
);
const first = ips[0];
const extra = ips.length - 1;
return (
<div>
<CopyToClipboard message={domain}>
{ips.length > 0 ? (
<Tooltip
content={<ResolvedIpsTooltip ips={ips} />}
delayDuration={300}
closeDelay={300}
side={"right"}
align={"start"}
alignOffset={-8}
interactive
keepOpenOnClick
contentClassName={cn(
"max-w-[18rem] max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 pr-4",
)}
>
{span}
</Tooltip>
) : (
span
)}
</CopyToClipboard>
</div>
<>
<div>
<CopyToClipboard message={domain}>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{domain}
</span>
</CopyToClipboard>
</div>
{first && (
<div className={"flex items-center gap-1.5 min-w-0"}>
<CopyToClipboard message={first}>
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
{first}
</span>
</CopyToClipboard>
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
</div>
)}
</>
);
};
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
const { t } = useTranslation();
const extra = ips.length - 1;
return (
<>
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</>
<Popover.Root>
<Popover.Trigger asChild>
<button
type={"button"}
onClick={(e) => e.stopPropagation()}
className={cn(
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
"wails-no-draggable cursor-pointer outline-none",
)}
>
{t("networks.ips.more", { count: extra })}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"start"}
sideOffset={6}
className={cn(
"z-50 w-64 max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 shadow-lg outline-none",
)}
>
<div
className={
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
}
>
{t("networks.ips.heading")}
</div>
<ul className={"flex flex-col"}>
{ips.map((ip) => (
<li key={ip}>
<CopyToClipboard
message={ip}
className={"px-1 py-0.5"}
>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
}
>
{ip}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -405,7 +450,11 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
mixed
? "translate-x-2.5"
: checked
? "translate-x-[1.125rem]"
: "translate-x-0.5",
)}
/>
</button>

View File

@@ -1,4 +1,12 @@
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
import {
ComponentType,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import * as Popover from "@radix-ui/react-popover";
@@ -8,8 +16,7 @@ import {
ArrowLeftIcon,
ArrowUpDownIcon,
ArrowUpIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
CableIcon,
ClockIcon,
GaugeIcon,
HandshakeIcon,
@@ -18,16 +25,16 @@ import {
LucideProps,
MapPinIcon,
MonitorIcon,
NetworkIcon,
Radio,
RefreshCwIcon,
WaypointsIcon,
ZapIcon,
} from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters";
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { mockOr, mockPeers } from "@/lib/mock";
@@ -156,7 +163,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
iconClassName={"top-[2px]"}
>
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
{shortenDns(selected.fqdn) || selected.ip}
{selected.fqdn || selected.ip}
</span>
</CopyToClipboard>
<Tooltip content={t("peers.details.refresh")}>
@@ -215,6 +222,7 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
const isConnected = peer.connStatus === "Connected";
const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
return (
@@ -233,37 +241,12 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
DASH
)}
</Row>
{peer.ipv6 && (
<Row icon={MapPinIcon} label={t("peers.details.netbirdIpv6")}>
<CopyToClipboard
message={peer.ipv6}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.ipv6} mono />
</CopyToClipboard>
</Row>
)}
{isConnected && (
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
</Row>
)}
{peer.relayed && (
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
<Row icon={CableIcon} label={t("peers.details.connection")}>
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
<ConnectionIcon size={13} />
{connectionLabel}
</span>
</Row>
)}
{peer.latencyMs > 0 && (
@@ -299,11 +282,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
{statusSince}
</Row>
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<IceRow
icon={MonitorIcon}
baseLabel={t("peers.details.localIce")}
@@ -316,6 +294,27 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
type={peer.remoteIceCandidateType}
endpoint={peer.remoteIceCandidateEndpoint}
/>
{peer.relayed && (
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
{peer.relayAddress ? (
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
)}
</Row>
)}
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
{peer.pubKey ? (
<CopyToClipboard
@@ -371,35 +370,67 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
);
};
// Single "View {n}" badge with a chevron that opens a click popover listing
// each routed resource on its own line with a click-to-copy entry. Avoids
// the repetitive "first item + N more" pattern given the row already has a
// "Resources" label and Layers icon.
const ResourcesValue = ({ networks }: { networks: string[] }) => (
<ResourcesPopover networks={networks} />
);
// "{N} Resources" trigger that opens a hover popover listing each routed
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover).
// Row value: first network inline, plus a "+N more" hover pill opening a
// popover with the full list. Mirrors the resolved-IPs pattern in
// Networks.tsx so the Resources tab and the peer detail row look consistent.
const ResourcesValue = ({ networks }: { networks: string[] }) => {
const first = networks[0];
const extra = networks.length - 1;
return (
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
<CopyToClipboard message={first}>
<TruncatedRowValue value={first} mono />
</CopyToClipboard>
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
</div>
);
};
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
const ResourcesMorePopover = ({
networks,
extra,
}: {
networks: string[];
extra: number;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
// Close with a small delay so the mouse can cross the sideOffset gap
// between trigger and content without the popover snapping shut.
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 300);
};
useEffect(() => () => cancelClose(), []);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
className={cn(
"shrink-0 inline-flex items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"px-2 py-1 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none",
)}
>
{networks.length}
<ChevronDownIcon
size={12}
className={cn("transition-transform duration-150", open && "rotate-180")}
/>
{t("peers.details.more", { count: extra })}
</button>
</Popover.Trigger>
<Popover.Portal>
@@ -407,11 +438,13 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
side={"bottom"}
align={"end"}
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
"z-50 max-w-[18rem] max-h-72 overflow-auto",
"z-50 w-64 max-h-72 overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-2 pr-4 shadow-lg outline-none",
"p-2 shadow-lg outline-none",
)}
>
<ul className={"flex flex-col"}>
@@ -420,7 +453,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
}
>
{n}
@@ -435,15 +468,37 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
);
};
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
className={cn(
"inline-block truncate align-middle min-w-0 max-w-[260px]",
mono && "font-mono",
)}
/>
);
// Truncates the value to one line with the row's available width; on hover,
// shows the full string in a tooltip — but only when actually clipped. Same
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown.
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [value]);
const span = (
<span
ref={ref}
className={cn(
"inline-block truncate align-middle min-w-0 max-w-[260px]",
mono && "font-mono",
)}
>
{value}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={value} delayDuration={600}>
{span}
</Tooltip>
);
};
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>

View File

@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200",
"text-sm text-nb-gray-100",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable cursor-default",
@@ -47,9 +47,15 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -62,10 +68,21 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
</span>
<span className={"w-4 shrink-0 flex items-center justify-center"}>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</DropdownMenuItem>
);

View File

@@ -1,18 +1,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { SearchInput } from "@/components/inputs/SearchInput";
import { EmptyState } from "@/components/empty-state/EmptyState";
import { NoResults } from "@/components/empty-state/NoResults";
import { latencyColor, shortenDns } from "@/lib/formatters";
import { latencyColor } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { mockOr, mockPeers } from "@/lib/mock";
import { PeerFilters, StatusFilter } from "./PeerFilters";
@@ -68,55 +67,27 @@ export const Peers = () => {
};
}, [peers]);
// Initial order: online-first, then alphabetically by fqdn / ip. Once
// peers have settled, positions become sticky — a peer flipping
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
// peers append at the end (sorted online-first / by-name among
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
// pattern.
//
// Stay in live-sort mode until every peer has reached a stable state
// (Connected or Idle). The daemon emits all peers as "Connecting" right
// after Up, which collapses the online-first sort into pure
// alphabetical — committing then would lock that incorrect order and
// the list would stay alphabetical even after every peer becomes
// Connected. Once nothing is Connecting we commit and go sticky.
// Initial order: online-first, then alphabetically by fqdn / ip. After
// that, positions are sticky — a peer flipping Connected→Connecting→Idle
// no longer jumps groups. Newly discovered peers append at the end
// (sorted online-first / by-name among themselves). Mirrors the
// networks-list and exit-nodes-list orderRef pattern.
const orderRef = useRef<string[]>([]);
const stickyRef = useRef(false);
const ordered = useMemo(() => {
const sortOnlineFirst = (list: PeerStatus[]) =>
[...list].sort((a, b) => {
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = peers
.filter((p) => !known.has(p.pubKey))
.sort((a, b) => {
const aOnline = isOnline(a.connStatus);
const bOnline = isOnline(b.connStatus);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = (a.fqdn || a.ip).toLowerCase();
const bName = (b.fqdn || b.ip).toLowerCase();
return aName.localeCompare(bName);
});
// Reset on empty (Disconnect → reconnect) so the next session
// re-sorts from scratch instead of replaying the stale orderRef.
if (peers.length === 0) {
orderRef.current = [];
stickyRef.current = false;
return [];
}
if (!stickyRef.current) {
const sorted = sortOnlineFirst(peers);
if (peers.every((p) => p.connStatus !== "Connecting")) {
orderRef.current = sorted.map((p) => p.pubKey);
stickyRef.current = true;
}
return sorted;
}
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
const kept = orderRef.current.filter((k) => byKey.has(k));
const known = new Set(kept);
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
(p) => p.pubKey,
);
})
.map((p) => p.pubKey);
const next = [...kept, ...fresh];
orderRef.current = next;
return next.map((k) => byKey.get(k)!);
@@ -136,11 +107,19 @@ export const Peers = () => {
if (isConnected && peers.length === 0) {
return (
<EmptyState
icon={MonitorSmartphoneIcon}
title={t("peers.empty.title")}
description={t("peers.empty.description")}
/>
<div
className={
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
}
>
<EmptyState
icon={LaptopIcon}
title={t("peers.empty.title")}
description={t("peers.empty.description")}
learnMoreUrl={"https://docs.netbird.io/how-to/getting-started"}
learnMoreTopic={t("nav.peers.title")}
/>
</div>
);
}
@@ -192,12 +171,15 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-default",
)}
>
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
<Tooltip
content={t(peerStatusLabelKey(peer.connStatus))}
side={"left"}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
@@ -208,12 +190,7 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
<div>
<CopyToClipboard message={peer.fqdn}>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
<TruncatedName name={peer.fqdn} />
</CopyToClipboard>
</div>
<div>
@@ -247,3 +224,35 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</ul>
);
};
// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a
// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout,
// and wrap in a Tooltip only when the text actually overflows. Avoids the
// "tooltip on hover even though everything fits" annoyance.
const TruncatedName = ({ name }: { name: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [name]);
const span = (
<span
ref={ref}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
>
{name}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={name} delayDuration={600}>
{span}
</Tooltip>
);
};

View File

@@ -1,216 +1,79 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusCircle } from "lucide-react";
import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { Label } from "@/components/typography/Label";
import { HelpText } from "@/components/typography/HelpText";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
// onCreate receives the sanitized profile name and the management URL the
// user picked (the cloud default for Cloud mode, the normalized self-
// hosted URL otherwise).
onCreate: (name: string, managementUrl: string) => void;
onCreate: (name: string) => void;
};
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
// `_` and `-` survive on the Go side. We additionally lowercase and convert
// spaces to `-` so what the user sees in the input is exactly what the
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
// ghost row and breaks subsequent delete.
const sanitizeProfileInput = (value: string): string =>
value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]/g, "");
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation();
const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
const [url, setUrl] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
// unreachable: soft warning. A second submit with the same URL proceeds
// anyway (matches the onboarding management step's behaviour for self-
// hosted servers behind internal DNS / VPN).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) {
setName("");
setNameError(null);
setMode(ManagementMode.Cloud);
setUrl("");
setUrlError(null);
setUnreachable(false);
setChecking(false);
setError(null);
}
}, [open]);
// Reset the URL warnings whenever the user edits the URL or flips mode —
// otherwise a stale warning lingers next to a just-corrected value.
useEffect(() => {
setUrlError(null);
setUnreachable(false);
}, [url, mode]);
const handleSubmit = async (e: FormEvent) => {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (checking) return;
const sanitized = sanitizeProfileInput(name);
if (sanitized.length === 0) {
setNameError(t("profile.dialog.required"));
nameRef.current?.focus();
const trimmed = name.trim();
if (trimmed.length === 0) {
setError(t("profile.dialog.required"));
inputRef.current?.focus();
return;
}
if (mode === ManagementMode.Cloud) {
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
onOpenChange(false);
return;
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return;
}
const target = normalizeManagementUrl(trimmed);
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: soft warning + bail. A second submit with the
// same URL skips re-checking (unreachable still true) so the user can
// proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
onCreate(sanitized, target);
onCreate(trimmed);
onOpenChange(false);
};
const handleNameChange = (value: string) => {
setName(sanitizeProfileInput(value));
if (nameError) setNameError(null);
const handleChange = (value: string) => {
setName(value);
if (error) setError(null);
};
// Live syntactic feedback: flag a non-empty, malformed URL as the user
// types instead of waiting for submit. Empty is not an error yet (handled
// on submit); the unreachable soft-warning only applies once syntax is OK.
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidthClass="max-w-md"
showClose={false}
className="py-7"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 px-7">
<div className="flex flex-col gap-2">
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.description")}
</HelpText>
</div>
<Input
ref={nameRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={nameError ?? undefined}
maxLength={64}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
</div>
<div className="flex flex-col gap-2">
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.managementHelp")}
</HelpText>
</div>
<div className="flex flex-col gap-3">
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
{mode === ManagementMode.SelfHosted && (
<Input
ref={urlRef}
autoFocus
placeholder={t("settings.general.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
)}
</div>
</div>
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
<Button
type="button"
variant={"secondary"}
size={"xs2"}
disabled={checking}
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant={"primary"}
size={"xs2"}
loading={checking}
>
{t("profile.dialog.submit")}
</Button>
</DialogActions>
<div className="px-8">
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
<Dialog.Description className="mt-1">
{t("profile.dialog.description")}
</Dialog.Description>
</div>
<div className="px-8 pt-3">
<Input
ref={inputRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleChange(e.target.value)}
error={error ?? undefined}
/>
</div>
<Dialog.Footer separator={false} className="pt-4">
<Button
type="submit"
variant="primary"
size={"md"}
className="w-full"
>
<PlusCircle size={14} />
{t("profile.dialog.submit")}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,7 +1,7 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog } from "@/lib/dialogs.ts";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
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/buttons/Button";
@@ -11,10 +11,6 @@ import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext";
import { useConfirm } from "@/contexts/DialogContext";
import { Settings as SettingsSvc } from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
@@ -27,50 +23,20 @@ export function ProfilesTab() {
profiles,
activeProfile,
loaded,
username,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
// The display order is established once — the active profile first, then
// the rest alphabetically — and then held stable for the lifetime of the
// window. Switching profiles must only flip the "active" badge, never
// reorder the rows (otherwise the row the user just clicked jumps to the
// top under their cursor). New profiles append at the end; removed ones
// drop out. `orderRef` is the source of truth for row order; the active
// badge is derived live from `activeProfile`.
const orderRef = useRef<string[]>([]);
const ordered = useMemo(() => {
const present = new Set(profiles.map((p) => p.name));
if (orderRef.current.length === 0) {
// First population: active-first, then alphabetical.
orderRef.current = [...profiles]
.sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
})
.map((p) => p.name);
} else {
// Preserve the established order; drop removed, append added.
const kept = orderRef.current.filter((n) => present.has(n));
const added = profiles
.map((p) => p.name)
.filter((n) => !orderRef.current.includes(n))
.sort((a, b) => a.localeCompare(b));
orderRef.current = [...kept, ...added];
}
const byName = new Map(profiles.map((p) => [p.name, p]));
return orderRef.current
.map((n) => byName.get(n))
.filter((p): p is Profile => p !== undefined);
}, [profiles, activeProfile]);
const sorted = [...profiles].sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
});
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
@@ -87,53 +53,40 @@ export function ProfilesTab() {
}
};
const handleSwitch = async (name: string) => {
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
};
const handleDeregister = async (name: string) => {
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.deregister.confirm");
const result = await warningDialog({
Title: i18next.t("profile.deregister.title"),
Message: i18next.t("profile.deregister.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
});
if (!ok) return;
if (result !== confirmLabel) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
};
const handleDelete = async (name: string) => {
if (name === DEFAULT_PROFILE) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("common.delete");
const result = await warningDialog({
Title: i18next.t("profile.delete.title"),
Message: i18next.t("profile.delete.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
});
if (!ok) return;
if (result !== confirmLabel) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
};
const handleCreate = async (name: string, managementUrl: string) => {
const handleCreate = async (name: string) => {
try {
await addProfile(name);
// Only persist a management URL for self-hosted; a fresh profile
// already defaults to NetBird Cloud, so writing the cloud URL
// would be a no-op. Do it before switching so any reconnect the
// switch triggers already targets the right deployment. SetConfig
// is keyed by profile name, so it writes the new profile even
// though it isn't active yet (adminUrl left empty — the daemon
// keeps its loaded value).
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: name, username, managementUrl }),
);
}
await switchProfile(name);
} catch (e) {
await errorDialog({
@@ -144,7 +97,7 @@ export function ProfilesTab() {
};
return (
<div>
<>
<SectionGroup title={t("settings.profiles.section.profiles")}>
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
@@ -155,12 +108,11 @@ export function ProfilesTab() {
>
<table className={"w-full text-sm"}>
<tbody>
{ordered.map((profile) => (
{sorted.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onSwitch={() => handleSwitch(profile.name)}
onDeregister={() => handleDeregister(profile.name)}
onDelete={() => handleDelete(profile.name)}
/>
@@ -168,7 +120,7 @@ export function ProfilesTab() {
</tbody>
</table>
{loaded && ordered.length === 0 && (
{loaded && sorted.length === 0 && (
<div
className={
"flex flex-col items-center justify-center py-10 text-center"
@@ -194,19 +146,18 @@ export function ProfilesTab() {
</SectionGroup>
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
</div>
</>
);
}
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSwitch: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
const { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email;
@@ -241,11 +192,8 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
</td>
<td className={"px-4 py-2.5 text-right align-middle"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
isDefault={profile.name === DEFAULT_PROFILE}
isActive={isActive}
onSwitch={onSwitch}
canDelete={profile.name !== DEFAULT_PROFILE}
onDeregister={onDeregister}
onDelete={onDelete}
/>
@@ -274,31 +222,14 @@ const TruncatedEmail = ({ email }: { email: string }) => {
};
type RowActionsProps = {
canSwitch: boolean;
canDeregister: boolean;
isDefault: boolean;
isActive: boolean;
onSwitch: () => void;
canDelete: boolean;
onDeregister: () => void;
onDelete: () => void;
};
const RowActions = ({
canSwitch,
canDeregister,
isDefault,
isActive,
onSwitch,
onDeregister,
onDelete,
}: RowActionsProps) => {
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
const deleteLabel = isDefault
? t("profile.delete.disabledDefault")
: isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
@@ -308,17 +239,11 @@ const RowActions = ({
hidden={!canDeregister}
/>
<ActionIconButton
label={deleteLabel}
label={t("profile.selector.delete")}
icon={Trash2}
onClick={onDelete}
variant={"danger"}
disabled={deleteDisabled}
/>
<ActionIconButton
label={t("profile.selector.switchTo")}
icon={LogIn}
onClick={onSwitch}
hidden={!canSwitch}
hidden={!canDelete}
/>
</div>
);
@@ -332,8 +257,6 @@ type ActionIconButtonProps = {
/** When true the button still occupies space (preserves row layout)
* but is invisible and non-interactive. */
hidden?: boolean;
/** When true the button is visible but non-interactive (greyed out). */
disabled?: boolean;
};
const ActionIconButton = ({
@@ -342,15 +265,13 @@ const ActionIconButton = ({
onClick,
variant = "default",
hidden = false,
disabled = false,
}: ActionIconButtonProps) => {
const button = (
<button
type={"button"}
onClick={disabled ? undefined : onClick}
onClick={onClick}
aria-label={label}
aria-hidden={hidden || undefined}
aria-disabled={disabled || undefined}
tabIndex={hidden ? -1 : undefined}
className={cn(
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
@@ -359,7 +280,6 @@ const ActionIconButton = ({
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
hidden && "opacity-0 pointer-events-none",
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)}
>
<Icon size={16} />
@@ -367,12 +287,7 @@ const ActionIconButton = ({
);
if (hidden) return button;
return (
<Tooltip
content={
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
}
side={"top"}
>
<Tooltip content={label} side={"top"}>
{button}
</Tooltip>
);

View File

@@ -1,16 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { AlertCircleIcon, ClockIcon } from "lucide-react";
import { ClockIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import {
Connection,
Profiles as ProfilesSvc,
Session,
WindowManager,
} from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors.ts";
@@ -36,7 +40,7 @@ function formatRemaining(seconds: number): string {
return `${pad(minutes)}:${pad(secs)}`;
}
export default function SessionExpirationDialog() {
export default function SessionAboutToExpireDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
@@ -64,20 +68,6 @@ export default function SessionExpirationDialog() {
return () => window.clearInterval(id);
}, [remaining]);
// Auto-close when the daemon flips back to Connected — covers extend
// flows started from outside this window (tray notification action,
// another UI surface) so the user isn't left staring at a stale dialog.
useEffect(() => {
const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
if (ev?.data?.status === "Connected") {
WindowManager.CloseSessionExpiration().catch(console.error);
}
});
return () => {
off();
};
}, []);
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
// opens the browser for the user to sign in, blocks on the daemon until
// the new deadline arrives. Tunnel stays up; success simply closes the
@@ -110,10 +100,10 @@ export default function SessionExpirationDialog() {
// relevant.
return;
}
WindowManager.CloseSessionExpiration().catch(console.error);
WindowManager.CloseSessionAboutToExpire().catch(console.error);
} catch (e) {
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Title: t("sessionAboutToExpire.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
@@ -131,10 +121,10 @@ export default function SessionExpirationDialog() {
profileName: active.profileName || "default",
username,
});
WindowManager.CloseSessionExpiration().catch(console.error);
WindowManager.CloseSessionAboutToExpire().catch(console.error);
} catch (e) {
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Title: t("sessionAboutToExpire.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
@@ -142,41 +132,33 @@ export default function SessionExpirationDialog() {
}
}, [busy, t]);
const close = useCallback(() => {
WindowManager.CloseSessionExpiration().catch(console.error);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
<SquareIcon icon={ClockIcon} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>
{expired
? t("sessionExpiration.expired")
? t("sessionAboutToExpire.expired")
: soon
? t("sessionExpiration.title")
: t("sessionExpiration.titleLater")}
? t("sessionAboutToExpire.title")
: t("sessionAboutToExpire.titleLater")}
</DialogHeading>
<DialogDescription>
{expired
? t("sessionExpiration.expiredDescription")
: soon
? t("sessionExpiration.description")
: t("sessionExpiration.descriptionLater")}
{soon
? t("sessionAboutToExpire.description")
: t("sessionAboutToExpire.descriptionLater")}
</DialogDescription>
</div>
{!expired && (
<div
className={
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
}
aria-live={"polite"}
>
{formatRemaining(remaining)}
</div>
)}
<div
className={
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
}
aria-live={"polite"}
>
{formatRemaining(remaining)}
</div>
<DialogActions>
<Button
@@ -185,20 +167,18 @@ export default function SessionExpirationDialog() {
size={"md"}
className={"w-full"}
onClick={stay}
disabled={busy}
disabled={expired || busy}
>
{expired
? t("sessionExpiration.authenticate")
: t("sessionExpiration.stay")}
{t("sessionAboutToExpire.stay")}
</Button>
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={expired ? close : logout}
onClick={logout}
disabled={busy}
>
{expired ? t("sessionExpiration.close") : t("sessionExpiration.logout")}
{t("sessionAboutToExpire.logout")}
</Button>
</DialogActions>
</ConfirmDialog>

View File

@@ -0,0 +1,55 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const EVENT_TRIGGER_LOGIN = "trigger-login";
const WINDOW_WIDTH = 360;
export default function SessionExpiredDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const signIn = useCallback(() => {
void Events.Emit(EVENT_TRIGGER_LOGIN);
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
const later = useCallback(() => {
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon icon={AlertCircleIcon} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>{t("sessionExpired.title")}</DialogHeading>
<DialogDescription>{t("sessionExpired.description")}</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={signIn}
>
{t("sessionExpired.signIn")}
</Button>
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={later}>
{t("sessionExpired.later")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -58,7 +58,7 @@ export function SettingsAbout() {
return (
<div
className={
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
}
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />

Some files were not shown because too many files have changed in this diff Show More