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
261 changed files with 5182 additions and 20586 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

@@ -29,10 +29,10 @@ jobs:
persist-credentials: false
- name: Generate FreeBSD port diff
run: bash -x release_files/freebsd-port-diff.sh
run: bash release_files/freebsd-port-diff.sh
- name: Generate FreeBSD port issue body
run: bash -x release_files/freebsd-port-issue-body.sh
run: bash release_files/freebsd-port-issue-body.sh
- name: Check if diff was generated
id: check_diff
@@ -367,7 +367,7 @@ jobs:
version: 11
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-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:
@@ -65,7 +65,7 @@ jobs:
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
if [ ${SIZE} -gt 62914560 ]; then
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
if [ ${SIZE} -gt 58720256 ]; then
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
exit 1
fi

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

@@ -70,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:
@@ -82,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:
@@ -105,13 +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
dst: /usr/share/applications/netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- gtk4
- webkitgtk6.0
- gtk3
- webkit2gtk4.1
- libayatana-appindicator-gtk3
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'

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

@@ -3,7 +3,6 @@ package iptables
import (
"errors"
"fmt"
"maps"
"net"
"slices"
@@ -422,17 +421,12 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
// Clone the maps so the persisted state holds a private snapshot. The
// live maps keep being mutated by subsequent rule operations while the
// state manager marshals the state from its periodic-save goroutine.
// Sharing them by reference races the two and aborts the process with a
// concurrent map iteration and write.
if m.v6 {
currentState.ACLEntries6 = maps.Clone(m.entries)
currentState.ACLIPsetStore6 = m.ipsetStore.clone()
currentState.ACLEntries6 = m.entries
currentState.ACLIPsetStore6 = m.ipsetStore
} else {
currentState.ACLEntries = maps.Clone(m.entries)
currentState.ACLIPsetStore = m.ipsetStore.clone()
currentState.ACLEntries = m.entries
currentState.ACLIPsetStore = m.ipsetStore
}
if err := m.stateManager.UpdateState(currentState); err != nil {

View File

@@ -4,7 +4,6 @@ package iptables
import (
"fmt"
"maps"
"net/netip"
"strconv"
"strings"
@@ -750,17 +749,11 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
// Clone the rule map so the persisted state holds a private snapshot. The
// live map keeps being mutated by subsequent rule operations while the
// state manager marshals the state from its periodic-save goroutine.
// Sharing it by reference races the two and aborts the process with a
// concurrent map iteration and write. The ipset counter guards itself
// during marshaling, so it can be shared directly.
if r.v6 {
currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteRules6 = r.rules
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteRules = r.rules
currentState.RouteIPsetCounter = r.ipsetCounter
}

View File

@@ -1,9 +1,6 @@
package iptables
import (
"encoding/json"
"maps"
)
import "encoding/json"
type ipList struct {
ips map[string]struct{}
@@ -22,14 +19,6 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
// clone returns a deep copy of the ipList with its own ips map.
func (s *ipList) clone() *ipList {
if s == nil {
return nil
}
return &ipList{ips: maps.Clone(s.ips)}
}
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -66,19 +55,6 @@ func newIpsetStore() *ipsetStore {
}
}
// clone returns a deep copy of the ipsetStore with its own ipsets map and
// independent ipList entries.
func (s *ipsetStore) clone() *ipsetStore {
if s == nil {
return nil
}
cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
for name, list := range s.ipsets {
cloned.ipsets[name] = list.clone()
}
return cloned
}
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok

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)
@@ -806,8 +798,6 @@ func (g *BundleGenerator) addSyncResponse() error {
AllowPartial: true,
}
g.maskSecrets()
jsonBytes, err := options.Marshal(g.syncResponse)
if err != nil {
return fmt.Errorf("generate json: %w", err)
@@ -820,27 +810,6 @@ func (g *BundleGenerator) addSyncResponse() error {
return nil
}
func (g *BundleGenerator) maskSecrets() {
if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
return
}
if g.syncResponse.NetbirdConfig.Flow != nil {
g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
}
if g.syncResponse.NetbirdConfig.Relay != nil {
g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
}
for i := range g.syncResponse.NetbirdConfig.Turns {
if g.syncResponse.NetbirdConfig.Turns[i] != nil {
g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
}
}
}
func (g *BundleGenerator) addStateFile() error {
sm := profilemanager.NewServiceManager("")
path := sm.GetStatePath()
@@ -1070,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)
@@ -1104,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

@@ -777,24 +777,13 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
serverIP := s.service.RuntimeIP()
var servers []netip.AddrPort
for _, ns := range originalNameservers {
if ns == serverIP {
log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
continue
}
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
}
if len(servers) == 0 {
if len(originalNameservers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -808,6 +797,11 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
var servers []netip.AddrPort
for _, ns := range originalNameservers {
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
}
handler.addRace(servers)
prev := s.fallbackHandler

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

@@ -716,13 +716,6 @@ func resolveURLsToIPs(urls []string) []net.IP {
// RouteSelector stores routes with default-on semantics, so without this every
// available exit node would report selected at once.
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
// An explicit user "deselect all" must not be overridden by management auto-apply.
// Auto-applying an exit node here would call SelectRoutes, which clears the
// deselect-all flag and re-enables every route the user turned off.
if m.routeSelector.IsDeselectAll() {
return
}
info := m.collectExitNodeInfo(clientRoutes)
if len(info.allIDs) == 0 {
return

View File

@@ -1,71 +0,0 @@
package routemanager
import (
"net/netip"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/routeselector"
"github.com/netbirdio/netbird/route"
)
func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
return route.HAMap{
haID: []*route.Route{
{
ID: "r-" + route.ID(netID),
NetID: netID,
Network: netip.MustParsePrefix("0.0.0.0/0"),
NetworkType: route.IPv4Network,
Enabled: true,
SkipAutoApply: skipAutoApply,
},
},
}
}
func TestUpdateRouteSelectorFromManagement(t *testing.T) {
t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
routes := exitNodeRoutes("exit1", false)
m.updateRouteSelectorFromManagement(routes)
require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
})
t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
routes := exitNodeRoutes("exit1", true)
m.updateRouteSelectorFromManagement(routes)
require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
})
t.Run("user selection is not overridden by management", func(t *testing.T) {
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
routes := exitNodeRoutes("exit1", true)
m.updateRouteSelectorFromManagement(routes)
require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
})
t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
m.routeSelector.DeselectAllRoutes()
routes := exitNodeRoutes("exit1", false)
m.updateRouteSelectorFromManagement(routes)
require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
})
}

View File

@@ -116,14 +116,6 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
// IsDeselectAll reports whether the user has explicitly deselected all routes.
func (rs *RouteSelector) IsDeselectAll() bool {
rs.mu.RLock()
defer rs.mu.RUnlock()
return rs.deselectAll
}
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()

View File

@@ -825,31 +825,3 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) {
})
}
}
// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the
// tray exit-node toggle disabling every non-exit routed network. The tray used
// to Select an exit node with append=false, which the RouteSelector treats as
// "drop the whole current selection" (default-on semantics) — so enabling an
// exit node also turned off every LAN/route the user had on. The fix sends
// append=true and lets the daemon's SelectNetworks handler deselect only the
// sibling exit nodes. This test models that handler sequence against the
// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other
// exit nodes) must leave non-exit routes untouched.
func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) {
rs := routeselector.NewRouteSelector()
all := []route.NetID{"exitA", "exitB", "lan1", "lan2"}
// User has two LAN routes on (default-on: nothing deselected => all selected).
require.True(t, rs.IsSelected("lan1"))
require.True(t, rs.IsSelected("lan2"))
// Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true)
// then deselects sibling exit nodes (exitB), never the LAN routes.
require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all))
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all))
assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on")
assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected")
assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected")
assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected")
}

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/errors.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.
@@ -110,8 +106,6 @@ The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
**Bundle shape is Chrome-extension JSON**: each key maps to `{ "message": "...", "description": "..." }`, not a bare string. `description` is **translator context for Crowdin** (which reads it natively from the source file) and is ignored at runtime — only `en/common.json` needs descriptions; target bundles carry just `message`. Both loaders strip back to a flat `key→message` map: Go's `loadBundle` (`bundle.go`) unmarshals into `map[string]bundleEntry` and flattens (so `BundleFor`/`Translate` signatures are unchanged); `frontend/src/lib/i18n.ts` maps each entry's `message` into the i18next `resources`. When editing a string, edit `message`; when a key's purpose isn't obvious from its name, add/update its `description` so translators (and screenshots auto-tagging) have context.
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
Package layout:
@@ -119,7 +113,7 @@ 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
@@ -127,22 +121,22 @@ The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on
## 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/errors.ts` (alongside `formatErrorMessage`), 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
The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`.
**All sends go through `safeSendNotification` (`tray_notify.go`)** — never call `Notifier.SendNotification`/`SendNotificationWithActions` directly. It swallows both errors *and panics*. The panic guard is load-bearing on Linux: Wails' notifier connects to the session bus in its `ServiceStartup`, and when that connect fails (headless box, no/unreachable `DBUS_SESSION_BUS_ADDRESS`, UI launched outside a desktop session) Wails logs the error but leaves the service registered with a **nil `*dbus.Conn`**. The next send then nil-derefs deep inside godbus (`Conn.getSerial`), and because sends run on a Wails event-dispatch goroutine the panic is fatal to the whole process — observed as a "catastrophic failure" crash on a Linux Mint VM when an update toast fired (`tray_update.go sendUpdateNotification`). `recover()` turns it into a logged no-op. The `tray_session.go` plain-notification fallback keys off the returned error, so a recovered panic (returns nil) correctly skips the fallback — the bus is dead, the plain send would panic too.
### Profile switching invariants
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
@@ -153,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

@@ -11,7 +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`
- 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

@@ -1,12 +1,14 @@
# NetBird Wails UI — Frontend Working Notes
The React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus several auxiliary windows opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
This is the React/TS frontend for the Wails v3 desktop UI. It runs inside the main Wails webview plus two auxiliary windows (`/#/settings` and `/#/browser-login`) opened by Go (`services/windowmanager.go`). For Go-side conventions and the daemon gRPC layer see `../CLAUDE.md`.
> **Keep these notes current.** Update this file whenever you change conventions, rename a context/provider, change the route table, add/remove a top-level dependency, or introduce a cross-cutting feature (i18n, theming, etc.). A cold-start agent should be able to orient from these notes without re-deriving the codebase.
> **Keep these notes current.** When working in this directory with Claude, update this file whenever you change conventions, rename a context/provider, shift the route table, add or remove a top-level dependency, or introduce a new cross-cutting feature (i18n, theming, telemetry, etc.). The aim is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
> **Work in progress.** Big chunks of the UI are still mocked, prototyped, or duplicated across screens that pre-date the current AppLayout. Anything marked "prototype" / "mocked" / "legacy" below should be assumed half-wired. The polished surface today is: the main connect toggle, the Settings window, the debug-bundle flow, the auto-update overlay, and the profile selector. Everything else is in flight.
## Stack & tooling
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by the connect toggle. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`darkMode: "class"`) + Radix primitives + i18next + `@wailsio/runtime`. React Router v7 `HashRouter` (Wails serves a static bundle). pnpm only — `package.json` is authoritative for deps and scripts. Class merging: `cn(...)` in `src/lib/cn.ts`. framer-motion is used only by `NetBirdConnectToggle`. `task dev` from `client/ui/` is the canonical dev entry point — it runs Vite on `WAILS_VITE_PORT || 9245`.
## Path aliases & bindings
@@ -14,173 +16,195 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
`bindings/` is gitignored and fully generated. A fresh clone has no `bindings/` on disk, so `pnpm typecheck` fails until you run `pnpm bindings` (or `wails3 generate bindings -clean=true -ts` from `client/ui/`) once. `wails3 dev` regenerates on its own.
## Routing (`app.tsx`)
## Routing (app.tsx)
`HashRouter`. Dialog routes are grouped under a parent `<Route path="dialog">` (URL grouping only, no shared layout); the two in-window routes sit under `<AppLayout>`. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`.
`HashRouter` with the following routes:
| Path | Component (module) | Layout | Window |
| Path | Component | Layout | Where it opens |
|---|---|---|---|
| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window |
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Settings auxiliary window |
| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | SSO browser-wait, always-on-top |
| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Install progress, always-on-top |
| `/dialog/session-expiration` | `SessionExpirationDialog` (modules/session/) | none | Session expiry warning, always-on-top |
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | First-launch onboarding |
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | App's single error surface, always-on-top |
| `/` | `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-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. |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
Auxiliary-window behaviour (sizing, always-on-top, create/destroy lifecycle) lives Go-side in `services/windowmanager.go` — see `../CLAUDE.md`. Frontend-relevant notes per window:
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.
- **Settings** — opened via `WindowManager.OpenSettings(tab)`. The window stays at `/#/settings` for its whole lifetime (no `SetURL` between opens, so `AppLayout`'s providers never remount). Active tab is React local state in `SettingsPage`, set from the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is driven in React by a `document.visibilitychange` listener (the Page Visibility API fires before WebKit throttles the hidden page, unlike a Go close-hook event which races `Hide` and flashes the previous tab for one frame).
- **install-progress** — owns the install-result polling + 5s daemon-down-grace, calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (user-driven enforced branch) and on the `installing` flip from `netbird:update:state` (force-install branch).
- **session-expiration** — `?seconds=` drives an mm:ss countdown; at zero it flips to the expired copy. Sign-in / Stay-connected emit `trigger-login`; Logout calls `Connection.Logout`.
- **welcome** — opened from Go's `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted step (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
- **error** — `errorDialog({Title, Message})` in `lib/errors.ts` opens this (not a native OS box). `title` is the window chrome title (set Go-side, not in the body); `message` is read from `useSearchParams` and rendered next to a danger `SquareIcon`, with a Close button (Escape also closes → `WindowManager.CloseError()`).
`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).
## Layouts
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:
- `Header` reads `useViewMode` (view-mode dropdown) and `useClientVersion` (update badge).
- `ViewModeProvider` wraps the whole of `Main` because both `Header` and `MainBody` read view mode. It calls `Window.SetSize` on the current Wails window, so it must not be visible to the Settings window.
- `NavSectionProvider` is mounted only inside the advanced-mode branch (`MainBody → AdvancedRightPanel`) — the default-mode view has no Peers/Resources/Exit Nodes tabs and no consumer of `useNavSection`. Default mode therefore skips the provider entirely.
- `Header.tsx`, `Navigation.tsx`, and `ConnectionStatusSwitch.tsx` are siblings of `Main.tsx` in `pages/main/` because nothing else uses them.
- **`pages/Settings.tsx`** owns the `h-12` `wails-draggable` strip at the top (so the macOS traffic-light buttons that float over the `MacTitleBarHiddenInset` window don't overlap content), then renders the vertical tabs — no view-mode, no nav, no header.
`AppLayout` is the only router-level layout. It mounts the shared provider stack and renders `<Outlet/>`:
## Directory layout (src/)
```
DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider
```
- `app.tsx` — root render + route table. The canonical registry of every route; scan this file to enumerate pages.
- `layouts/AppLayout.tsx` — the router-level layout. Mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) and renders `<Outlet/>`. (`layouts/` also holds `AppRightPanel.tsx`, see below.)
- `modules/<feature>/` — every feature owns its own folder: page entry (named `<Feature>Page.tsx`), local components, and everything else it needs:
- `modules/main/``MainPage.tsx` + main-window chrome (`Header.tsx`, `ConnectionStatusSwitch.tsx`).
- `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/``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/`.
- `DialogProvider` is outermost (and outside the daemon gate) so `useConfirm()` works regardless of daemon state.
- `StatusProvider` owns the single `DaemonFeed.Get` + `netbird:status` subscription and **only renders its children when the daemon is reachable** — otherwise it short-circuits to `<DaemonUnavailableOverlay/>`. Consequence: every downstream context can assume the daemon is reachable at mount, so no per-context availability gating. When the daemon flips unavailable the whole subtree unmounts and remounts fresh on return.
- Order matters: `SettingsContext` (mounted in `SettingsPage`) depends on `ProfileContext`; `ClientVersionContext` reads `StatusContext` events.
`AppRightPanel` (in `layouts/`) is the shared content-panel shell used by the advanced-mode body; it supports an overlay slot (the peer-detail panel slides over it).
Page-specific chrome and providers live in the page, not the layout:
- **`MainPage`** (main window only) mounts `ViewModeProvider` (wraps the whole page — both `MainHeader` and `MainBody` read view mode; it calls `Window.SetSize`, so it must not be visible to the Settings window), `NetworksProvider`, and `PeerDetailProvider`. `NavSectionProvider` is mounted **only** inside the advanced-mode branch — default mode has no Peers/Networks tabs and no consumer of `useNavSection`.
- **`SettingsPage`** owns the `wails-draggable` strip at the top (so the macOS traffic-light buttons floating over the frameless window don't overlap content), then renders the vertical tabs.
## Directory layout (`src/`)
- `app.tsx`root render + route table. The canonical registry of every route. Also wires init-time bootstrap (`initLogForwarding`, `welcome`, `initI18n`, `initPlatform`) before first render.
- `layouts/``AppLayout.tsx` (the only router-level layout) and `AppRightPanel.tsx` (shared content-panel shell).
- `modules/<feature>/` — each feature owns its folder: a `*Page.tsx` entry where applicable, plus its local components.
- `main/``MainPage.tsx`, `MainHeader.tsx`, `MainConnectionStatusSwitch.tsx` (connect toggle + the `startLogin` SSO orchestrator), `MainExitNodeSwitcher.tsx`.
- `main/advanced/` — advanced-mode-only surfaces: `Navigation.tsx` (Peers/Networks tab switch) plus `peers/` (`Peers.tsx`, `PeerDetailPanel.tsx`, `PeerFilters.tsx`) and `networks/` (`Networks.tsx`, `NetworkFilters.tsx`). There is no exit-nodes sub-module — exit-node state lives in `NetworksContext` and the UI is `MainExitNodeSwitcher` (shown in default mode too).
- `settings/``SettingsPage.tsx`, `SettingsNavigation.tsx`, `SettingsSection.tsx`, `SettingsSkeleton.tsx`, and the tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSecurity`, `SettingsSSH`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). The Profiles tab is `modules/profiles/ProfilesTab.tsx`.
- `profiles/``ProfileDropdown.tsx` (header), `ProfileCreationModal.tsx`, `ProfilesTab.tsx` (settings table), `ProfileAvatar.tsx`. Context in `contexts/ProfileContext.tsx`. The creation modal collects a profile name + management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + `useManagementUrl`); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the `managementUrl` onto it (keyed by profile name, before switching), then switches. Row actions confirm via `useConfirm()`.
- `welcome/``WelcomeDialog.tsx` (orchestrator) + `WelcomeStepTray.tsx`, `WelcomeStepManagement.tsx`. The management step renders only when active profile is `"default"`, the profile email is empty, and the management URL is cloud-default-or-empty (`shouldShowManagementStep`). Self-hosted URL reachability is a soft warning (`useManagementUrl.checkManagementUrlReachable`) — the user can re-click Continue to proceed past a failed check.
- `login/``LoginWaitingForBrowserDialog.tsx` (SSO browser-wait window).
- `session/``SessionExpirationDialog.tsx`.
- `auto-update/``UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context in `contexts/ClientVersionContext.tsx`.
- `error/``ErrorDialog.tsx`.
- `contexts/` — every React context as a flat file: `StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`. Mental model: "where is the X context? `contexts/XContext.tsx`."
- `components/` — presentational primitives, no daemon RPCs, no router:
- `buttons/``Button`, `IconButton`.
- `inputs/``Input`, `SearchInput`.
- `dialog/``Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation, usually driven via `useConfirm()`).
- `switches/``SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
- `typography/``Label`, `HelpText`.
- `empty-state/``EmptyState`, `NoResults`, `NotConnectedState`, `DaemonUnavailableOverlay`.
- Flat at root: `Badge`, `CopyToClipboard`, `DropdownMenu`, `SquareIcon`, `Tooltip`, `TruncatedText`, `VerticalTabs`, `LanguagePicker`, `ManagementServerSwitch`.
- `hooks/``useAutoSizeWindow.ts` (auto-size + `Window.Show` for auxiliary dialogs), `useKeyboardShortcut.ts`, `useManagementUrl.ts` (management-URL helpers: `CLOUD_MANAGEMENT_URL`, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`).
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts` (`formatErrorMessage` + the `errorDialog({Title, Message})` window wrapper), `formatters.ts` (byte/latency/relative-time + `shortenDns`), `sorting.ts` (`reconcileOrder` — order-preserving list reconciliation shared by the peers/networks/profiles lists), `i18n.ts`, `logs.ts` (forwards console + uncaught errors to the Go log pipeline), `platform.ts` (`isMacOS`/`isWindows`), `welcome.ts`.
- `assets/` — fonts, logos, flags.
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`). 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`.
- `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`.
- `assets/`fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
## Wails event bus
Subscribe with `Events.On(name, handler)`; the handler receives `{ data: <typed payload> }`. Event-name strings live next to their usage (no central TS registry). Prefer one subscription at the context level over per-screen — the bus is process-wide and each `Events.On` adds an emit-time fan-out.
Subscribe with `Events.On(name, handler)`. The handler receives `{ data: <typed payload> }`. The event name strings live next to their usage (no central registry on the TS side).
| Event name | Payload | Emitted by | Consumed by |
| Event name (string) | Payload | Emitted by | Consumed by |
|---|---|---|---|
| `netbird:status` | `Status` | `services/peers.go` | `StatusContext` (the only subscriber) |
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `ProfileContext` — refreshes so a tray-initiated switch paints in the UI |
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing` |
| `netbird:settings:open` | `string` (tab id) | `services/windowmanager.go OpenSettings` (before `Show`) | `SettingsPage``setActive(e.data)`. Reset-on-close is the `visibilitychange` listener, not this event. |
| `netbird:preferences:changed` | `{ language }` | Go after `SetLanguage` / `SetViewMode` | `lib/i18n.ts` — calls `i18next.changeLanguage` so a flip from any window paints everywhere |
| `browser-login:cancel` | (none) | `LoginWaitingForBrowserDialog` Cancel button **or** Go on window close | `MainConnectionStatusSwitch`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
| `trigger-login` | (none) | `services.EventTriggerLogin` (reserved; no Go emitter today) | `MainConnectionStatusSwitch` subscribes and runs `startLogin()` |
| `netbird:status` | `Status` | `services/peers.go statusStreamLoop` | `contexts/StatusContext` (`useStatus`) |
| `netbird:event` | `SystemEvent` | `services/peers.go toastStreamLoop` | Not currently subscribed on the TS side — Status is read via `useStatus().status.events` instead. The tray (Go) consumes it for OS notifications. |
| `netbird:profile:changed` | `ProfileRef` | `services/profileswitcher.go SwitchActive` | `contexts/ProfileContext` refreshes so a tray-initiated switch paints in the React UI. |
| `netbird:update:available` | `UpdateAvailable` | `services/peers.go fanOutUpdateEvents` | Not directly subscribed on the TS side; `ClientVersionContext` derives `updateVersion` from `status.events` metadata instead. |
| `netbird:update:progress` | `UpdateProgress` | same | Drives the tray. UI side: `WindowManager.OpenInstallProgress` is what opens the install window; the React listener for `installing` flips lives in `ClientVersionContext`. |
| `netbird:update:state` | `UpdateState` | `services/peers.go fanOutUpdateEvents` + the updater's `progress_window:show` translator | `modules/auto-update/ClientVersionContext` — single source of truth for `updateAvailable / version / enforced / installing`. |
| `browser-login:cancel` | (no payload) | `BrowserLogin` page (frontend) when user clicks Cancel **or** Go `services/windowmanager.go` when user closes the BrowserLogin window | `pages/main/ConnectionStatusSwitch.tsx`'s `startLogin()` to abort the in-flight `WaitSSOLogin` |
| `trigger-login` | (no payload) | Reserved (`services.EventTriggerLogin`); `pages/main/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()` when fired. No Go-side emitter today. |
| `netbird:settings:open` | `string` (tab id, e.g. `"general"`, `"profiles"`) | `services/windowmanager.go OpenSettings` (before Go calls `Show`) | `modules/settings/SettingsPage.tsx` — just `setActive(e.data)`. Reset-on-close is **not** driven by this event — see the `visibilitychange` listener in the same file. |
`netbird:event`, `netbird:update:available`, and `netbird:update:progress` are emitted Go-side for the tray but **not** subscribed on the TS side — the UI derives the same info from `useStatus().status.events`.
If you wire a new daemon-event subscriber on the TS side, prefer subscribing once at the context level rather than per-screen — the Wails event bus is process-wide and each `Events.On` adds an emit-time fan-out.
## Contexts and state
State that crosses screens/windows lives in context, each provider mounted exactly once.
State that crosses screens / windows lives in context. Each provider is mounted exactly once inside `AppLayout` or `SettingsLayout`.
- **`useStatus`** (`StatusContext`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. Owns the single `DaemonFeed.Get` + `netbird:status` subscription and the daemon gate (see Layouts). `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag.
- **`ProfileContext`** — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` + a `refresh()`.
- **`SettingsContext`** — `setField` / `saveField` / `saveFields` / `saveNow` over `Settings.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null`. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect — `save` drops the field when it equals the mask.
- **`DebugBundleContext`** — stages `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
- **`ClientVersionContext`** — seeds from `Update.GetState()`, subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. Three branches:
1. `available && !enforced` — download-only; `UpdateVersionCard` → opens GitHub releases.
2. `available && enforced && !installing` — user-driven; `triggerUpdate` opens the install-progress window then calls `Update.Trigger()`.
3. `available && enforced && installing` — daemon already installing; the flip auto-opens the install-progress window.
- **`NetworksContext`** — routed networks + exit nodes derived from `status.networks`; optimistic overrides for instant toggle feedback. **`PeerDetailContext`** — which peer detail panel is open in advanced view. **`NavSectionContext`** — the advanced-mode Peers/Networks tab selection.
- **`useStatus`** (`contexts/StatusContext.tsx`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. The provider owns a single `Peers.Get()` + `netbird:status` subscription and renders `<DaemonUnavailableOverlay/>`. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down.
### View mode + no client-side persistence
- **`ProfileContext`** (`modules/profiles/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`.
`ViewModeProvider` (`contexts/ViewModeContext.tsx`, mounted in `MainPage`) owns `viewMode: "default" | "advanced"`, consumed via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH`: Default 380, Advanced 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is `setFrame:` (frame, incl. ~28px title bar) while initial `windowNew` uses `initWithContentRect:` (content). Passing a constant would chop ~28px off the content area on the first switch. `main.go` opens the window at the saved width so there's no 380→900 flash on launch; the provider hydrates from `Preferences.Get()` on mount without triggering a resize.
- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `<SettingsSkeleton/>` while `config === null` so tabs never see null. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"`.
**No `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job: settings`SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`.
- **`DebugBundleProvider` + `useDebugBundle`** (`contexts/DebugBundleContext.tsx`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`.
- **`ClientVersionContext`** (`modules/auto-update/`) — seeds from `Update.GetState()` and subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. **Three branches**:
1. `available && !enforced` — download-only. `UpdateVersionCard` shows "Version X is available for download" + "Download installer" → opens GitHub releases.
2. `available && enforced && !installing` — user-driven enforced. `UpdateVersionCard` shows "Version X is available for install" + "Install now" → `triggerUpdate` opens `/install-progress` window then calls `Update.Trigger()`.
3. `available && enforced && installing` — daemon already installing (force-install). The `installing` flip auto-opens `/install-progress` via `WindowManager.OpenInstallProgress`.
### Default/Advanced view + no client-side persistence
The `ViewModeProvider` (`src/lib/viewMode.tsx`, mounted in `AppLayout`) owns a `viewMode: "default" | "advanced"` state and is consumed by `Header.tsx`'s "more" dropdown via `useViewMode()`. `setViewMode` updates state, calls `Window.SetSize(width, <live frame height>)`, and persists via `Preferences.SetViewMode`. Widths live in `VIEW_WIDTH` at the top of `viewMode.tsx`: Default = 380, Advanced = 900. **The height is intentionally not asserted** — we read the current frame height via `Window.Size()` and pass it back, because Wails' macOS `windowSetSize` is implemented as `setFrame:` (frame, incl. ~28px title bar) while the initial `windowNew` uses `initWithContentRect:` (content). Passing a constant 640 would chop ~28px off the content area on the first switch and visually shift everything inside (the connect toggle is `justify-center` in a column that depends on the parent's height). Reusing the live height keeps content area stable across all switches. The view is persisted user-side (see Go-side `preferences.Store`): `main.go` opens the main window at the saved width so the user never sees a 380→900 flash on launch, and the provider hydrates its React state from `Preferences.Get()` in a mount effect (no resize triggered there — Go already sized it). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`).
## Localisation (i18n)
Bootstrap in `src/lib/i18n.ts`, awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, glob-imports every bundle from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), inits i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to `netbird:preferences:changed` so a flip from any window calls `i18next.changeLanguage` here.
Bootstrap lives in `src/lib/i18n.ts` and is awaited before render in `app.tsx`. It reads the current language from `Preferences.Get()`, statically imports every bundle JSON (`en/common.json`, `de/common.json`, `hu/common.json` today) from the shared tree at `client/ui/i18n/locales/` (sibling of the Go i18n package — same JSON drives both tray and React), initialises i18next with `fallbackLng: "en"` and `interpolation: { prefix: "{", suffix: "}" }`, and subscribes to the `netbird:preferences:changed` Wails event so a flip from any window (tray, settings, another renderer) calls `i18next.changeLanguage` here.
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go "unset" signal). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each, and picks the first base code (`de` from `de-DE`) with a shipped bundle — then `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back. No match (or store unreachable) falls through to `en`. From the second launch the persisted value wins.
**First-run browser-language detection.** When no preferences file exists, `Preferences.Get()` returns `language: ""` (the Go-side "unset" signal`preferences.Store` no longer pre-fills a default). `initI18n` walks `navigator.language` + `navigator.languages`, lowercases each tag, and picks the first base code (`de` from `de-DE`) that has a shipped bundle — then calls `Preferences.SetLanguage(detected)` fire-and-forget so the next launch reads it back without re-detecting. If nothing matches (or the store is unreachable) the session falls through to `en`. From the second launch onward, the Go-side persisted value wins and detection is skipped. The tray (`localizer.go`) treats empty as English via its own fallback to `i18n.DefaultLanguage` so the first menu render before SetLanguage round-trips is still readable.
**Usage.** Default to the hook:
The frontend deliberately uses **no `localStorage` / `sessionStorage` / cookies anywhere** — persistence is the Go side's job (settings via `SettingsContext.save → SetConfig`, language via `Preferences.SetLanguage`). The previous wide-panel and settings-tab persistence experiments were removed; every window opens at its baseline state.
**Usage in components.** Default to the hook:
```ts
import { useTranslation } from "react-i18next";
const { t } = useTranslation();
t("settings.tabs.general");
t("update.card.versionAvailable", { version: updateVersion }); // placeholders
return <span>{t("settings.tabs.general")}</span>;
// with placeholders:
t("update.card.versionAvailable", { version: updateVersion })
```
Outside React (module-scope event handlers, error titles) import the instance directly: `import i18next from "@/lib/i18n"`.
For strings outside React (event handlers in modules, `Dialogs.Error` titles set from `useDebugBundle`, `useManagementUrl`, `ProfileContext`, `SettingsContext`) import the i18next instance directly:
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` in Chrome-extension JSON shape: each key maps to `{ "message": "...", "description": "..." }`. `description` is translator context for Crowdin (read from the source file, ignored at runtime) — only `en/common.json` carries descriptions; target bundles carry just `message`. `lib/i18n.ts` strips each entry to its `message` when building the i18next `resources`, so `t()` lookups are unchanged. Placeholders use single braces: `"Install version {version}"`. Add a key to `en/common.json` first (the fallback), then to every other locale. Missing keys fall back to English, then to the key itself (so the gap is visible in the UI).
```ts
import i18next from "@/i18n";
await Dialogs.Error({ Title: i18next.t("settings.error.saveTitle"), Message: ... });
```
**Translating bundles.** `client/ui/i18n/TRANSLATING.md` is the authoritative brief for actually producing or reviewing a translation — written for any translator (human or AI agent). It carries the product context, the file-format rules, the placeholder/`\n`/plural constraints (the app has only a one/other plural split — no ICU rules), the per-language do-vs-don't-translate glossary (e.g. "Exit Node" stays English in de/hu but is translated in ru/es/fr/it/pt/zh), and the new-language + review procedures. Read it before adding or editing any locale; keep its glossary/procedures current when conventions change.
**Confirm dialogs.** `Dialogs.Warning` resolves with the **button label string** — not an index. After translation, those labels change per language. Pin the label into a variable so the comparison stays correct:
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` (follow `TRANSLATING.md`) and append the row to `_index.json`. No flag asset is needed — `LanguagePicker.tsx` deliberately ships no flags ("flags represent countries, not languages"). `lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`) — no code change needed to wire a new locale.
```ts
const confirmLabel = t("profile.delete.message"); // wrong example — show your real key
const cancelLabel = t("common.cancel");
const result = await Dialogs.Warning({ Title, Message, Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
]});
if (result !== confirmLabel) return;
```
**What gets translated.** Every user-facing string. Don't add hard-coded English — add the key, then `t()`. Internal log strings and the `Update failed` fallback fed into `classifyError()` are not translated.
Compare against the variable, never against an English literal.
## Login flow (`startLogin` in `MainConnectionStatusSwitch.tsx`)
**Bundle files.** Keys live in `client/ui/i18n/locales/<code>/common.json` as a flat key→string map (`"settings.tabs.general": "General"`). Placeholders use single braces: `"Install version {version}"`. Adding a key: add to `en/common.json` first (the fallback), then every other locale. Missing keys fall back to English; if even that misses, i18next returns the key itself so the gap is visible in the UI rather than blank.
The SSO flow is a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
**Adding a language.** Drop `client/ui/i18n/locales/<code>/common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `<code>.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship**`LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files.
**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/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`)
The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlight` guard so a double-click can't fire two concurrent flows. Sequence:
1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user.
2. If SSO is needed (`needsSsoLogin`):
- `WindowManager.OpenBrowserLogin(uri)` opens the sign-in popup (hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`).
- The dialog fires `Connection.OpenURL(uri)` from its mount effect (done from the dialog, not `startLogin`, so the browser doesn't race the still-hidden popup).
- `Promise.race(WaitSSOLogin, browser-login:cancel)`.
- On cancel: cancel the in-flight `WaitSSOLogin` gRPC so the daemon drops the abandoned device code.
2. If the daemon needs SSO (`needsSsoLogin`):
- `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window (Hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`).
- `LoginWaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top.
- `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first.
- On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`).
3. `Connection.Up({})` to bring the new session up.
`onSettled` (releasing the caller's React-level guard) fires the instant the flow ends — **before** the error dialog — never gated on the dialog. Errors that aren't cancellations surface via `errorDialog`. This is the only SSO entry point; there's no `/login` route — wire any new SSO trigger through here.
Errors that aren't cancellations surface via `Dialogs.Error`.
This is the only SSO entry point used by the polished Main UI. There is no `/login` route in `app.tsx`; if you add one, wire it up here rather than introducing a parallel SSO flow.
## Components
`src/components/` holds presentational primitives (no daemon RPCs, no router) — see the directory listing. Settings rows use `FancyToggleSwitch` inside `<SectionGroup title=…>` (section-group dimming via `disabled` → greyed + `pointer-events-none`). In-app modals use the Radix `Dialog` primitive in the main webview; the two auxiliary OS windows (Settings, BrowserLogin) are created Go-side via `WindowManager`.
## Dialogs convention
**Errors → `errorDialog({Title, Message})` from `src/lib/errors.ts`** (which also exports `formatErrorMessage`), never `Dialogs.*` from `@wailsio/runtime`. Despite the name it opens the custom always-on-top `/#/dialog/error` window via `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`), not a native OS box. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **`errorDialog()` resolves as soon as the window opens — it does not block until dismissed.**
**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.
For **confirmations**, use `useConfirm()` from `contexts/DialogContext.tsx``const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` mounted at the provider level. Used by the Profiles tab and the management-server cloud switch.
**Skip dialogs entirely** for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full 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
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background `nb-gray-950`); `netbird` is brand orange (`#f68330`). New code uses `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by the connect toggle. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), under `src/assets/fonts/`.
Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = `nb-gray-950`); `netbird` is brand orange (`#f68330`). The Flowbite-style `gray`/`red`/`yellow`/`...` palettes are legacy — only use them inside `screens/*`; new code sticks to `nb-gray` + `netbird` + semantic dot colors (`green-500`, `red-500`, `yellow-500`). `bg-conic-netbird` and the `pulse-reverse` / `spin-slow` / `ping-slow` keyframes are used only by `NetBirdConnectToggle`. Fonts: Inter Variable (sans) + JetBrains Mono Variable (mono), shipped under `src/assets/fonts/`.
## Wails-specific quirks
- **Window dragging.** Class `wails-draggable` on regions that should drag the OS window (headers, the Settings title strip, dialog wrappers). `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
- **Webview asset access.** Reference assets through Vite: `import url from "@/assets/.../foo.svg"`. Absolute filesystem paths don't work in dev or prod.
- **`Window.SetSize(w, h)`.** Called from `ViewModeContext`'s `setViewMode`. Height is read fresh from `Window.Size()` and re-passed — see the View mode section for why a constant would shrink the content area.
- **Main-window width.** Windows uses a slightly narrower content width than macOS to compensate for the OS frame Wails counts differently (`MainPage``isWindows() ? 364 : 380`; see wails/wails#3260).
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` (legal links) and the BrowserLogin "Try again". `SettingsAbout` has a `window.open` fallback for when Wails refuses (non-http schemes are rejected).
- **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, dialog wrappers like `ConfirmDialog`). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click.
- **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod.
- **`Window.SetSize(w, h)`.** Called from `viewMode.tsx`'s `setViewMode` when the user flips the view-mode dropdown. Width comes from `VIEW_WIDTH` (380 / 900); height is read fresh from `Window.Size()` and re-passed, because Wails' macOS `windowSetSize` treats height as the frame (including title bar) while initial window creation treats it as content — re-asserting a constant would shrink the content area by one title-bar height. See the "Default/Advanced view" section above.
- **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails).
## 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/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
Full per-service binding signatures, push-event payloads, and model field shapes live in `WAILS-API.md` (sibling). Every service method returns `$CancellablePromise<T>``await` and ignore `.cancel()` in practice. Regenerate bindings via `pnpm bindings` after any Go-side change.
## Useful references
- `WAILS-API.md` (sibling) — full per-service binding signatures, push-event payloads, and model field shapes. Every method returns `$CancellablePromise<T>` (`await` and ignore `.cancel()` in practice). Regenerate via `pnpm bindings` after any Go-side change.
- `WAILS-API.md` (sibling) — full binding signatures, push events, and model shapes.
- Wails v3 dialog signatures: `node_modules/@wailsio/runtime/types/dialogs.d.ts`.
- Wails v3 docs (may 403 from some clients): https://v3.wails.io/
- `../CLAUDE.md` Go-side conventions, service registration, profile-switching policy, auxiliary-window lifecycle, Linux tray internals.
- `../CLAUDE.md` for Go-side conventions, service registration, profile-switching policy, and Linux tray internals.

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";
@@ -15,47 +14,43 @@ import { welcome } from "@/lib/welcome";
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
import { initI18n } from "@/lib/i18n";
import { initPlatform } from "@/lib/platform";
import { initLogForwarding } from "@/lib/logs";
// Must run first so even init-time logs reach the Go log pipeline.
initLogForwarding();
welcome();
Promise.all([
initI18n().catch((e) => {
// Surface init failures in the console so a misconfigured glob
// doesn't quietly blank the UI; render anyway with i18next in
// whatever state it ended up in (t() will fall back to keys).
console.error("i18n init failed:", e);
}),
initPlatform().catch((e) => {
console.error("platform init failed:", e);
}),
]).finally(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(
])
.finally(() => {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="dialog">
<Route
path="browser-login"
element={<LoginWaitingForBrowserDialog />}
/>
<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 />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to={"/"} replace />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}
/>
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
);
});
);
});

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

@@ -5,8 +5,12 @@ import { cn } from "@/lib/cn";
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
type Props = HTMLAttributes<HTMLSpanElement> & {
/** Visual color scheme. Defaults to `info` (sky), used as the
* "Active profile" indicator. */
variant?: BadgeVariant;
/** Optional leading lucide icon. */
icon?: ComponentType<LucideProps>;
/** Override icon size. Defaults to 10px to match the compact pill. */
iconSize?: number;
};
@@ -19,6 +23,10 @@ const VARIANT_CLASSES: Record<BadgeVariant, string> = {
danger: "bg-red-900 border border-red-700 text-red-200",
};
// Pill shape sized for inline use next to text. `top-px` nudges the badge
// down so its midline aligns with the surrounding text baseline; `leading-none`
// lets the small text sit flush in the pill without the line-height padding
// inflating it.
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
ref,

View File

@@ -1,14 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
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;
@@ -17,7 +10,6 @@ type CopyToClipboardProps = {
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
variant?: CopyToClipboardVariant;
};
export const CopyToClipboard = ({
@@ -28,17 +20,9 @@ export const CopyToClipboard = ({
className,
iconClassName,
alwaysShowIcon = false,
variant = "default",
}: CopyToClipboardProps) => {
const wrapperRef = useRef<HTMLButtonElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -48,29 +32,22 @@ export const CopyToClipboard = ({
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 500);
setTimeout(() => setCopied(false), 500);
} catch {
//
}
};
return (
<button
type="button"
<div
ref={wrapperRef}
onClick={handleClick}
className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
"inline-flex gap-2 items-center group/copy cursor-pointer wails-no-draggable",
className,
)}
>
<span
className={cn(
"relative truncate min-w-0",
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
>
<span className={cn("relative truncate min-w-0")}>
{children}
<span
className={
@@ -102,6 +79,6 @@ export const CopyToClipboard = ({
)}
/>
</span>
</button>
</div>
);
};

View File

@@ -3,21 +3,53 @@ 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 { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { errorDialog } from "@/lib/dialogs.ts";
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";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { formatErrorMessage } from "@/lib/errors";
// No flag icons: flags represent countries, not languages. 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();
@@ -50,8 +82,10 @@ export function LanguagePicker() {
);
const select = async (code: string) => {
setOpen(false);
if (busy || code === i18n.language) return;
if (busy || code === i18n.language) {
setOpen(false);
return;
}
setBusy(true);
try {
await Preferences.SetLanguage(code as LanguageCode);
@@ -62,6 +96,7 @@ export function LanguagePicker() {
});
} finally {
setBusy(false);
setOpen(false);
}
};
@@ -86,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>
@@ -102,10 +137,10 @@ export function LanguagePicker() {
className={cn(
"w-[var(--radix-popover-trigger-width)]",
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"origin-[var(--radix-popover-content-transform-origin)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=top]:slide-in-from-bottom-1",
"duration-150 ease-out",
@@ -158,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,24 +7,21 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
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

@@ -2,32 +2,19 @@ import { ComponentType } from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
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",
};
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (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,11 +9,8 @@ type Props = {
align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number;
sideOffset?: number;
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
contentClassName?: string;
closeDelay?: number;
};
export const Tooltip = ({
@@ -23,50 +20,31 @@ 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);
};
return (
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
<RTooltip.Provider
delayDuration={delayDuration}
disableHoverableContent={!interactive}
>
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
<RTooltip.Trigger
asChild
onPointerEnter={() => {
hoveringRef.current = true;
cancelClose();
}}
onPointerLeave={() => {
hoveringRef.current = false;
scheduleClose();
setOpen(false);
}}
>
{children}
@@ -76,17 +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()}
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,32 +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;
};
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

@@ -3,32 +3,32 @@ import * as Tabs from "@radix-ui/react-tabs";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
const Root = forwardRef<
HTMLDivElement,
Omit<Tabs.TabsProps, "orientation">
>(function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.Root
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
{...props}
/>
);
});
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
function VerticalTabsList({ className, ...props }, ref) {
return (
<Tabs.Root
<Tabs.List
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
{...props}
/>
);
},
);
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
{ className, ...props },
ref,
) {
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
{...props}
/>
);
});
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
@@ -36,47 +36,54 @@ type TriggerProps = Tabs.TabsTriggerProps & {
adornment?: ReactNode;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
<Icon
size={iconSize}
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
});
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
},
);
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
{ className, ...props },
ref,
) {
return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />;
});
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
function VerticalTabsContent({ className, ...props }, ref) {
return (
<Tabs.Content
ref={ref}
className={cn("outline-none", className)}
{...props}
/>
);
},
);
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });

View File

@@ -1,6 +1,6 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, Copy, Loader2 } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
import { cn } from "@/lib/cn";
@@ -10,13 +10,12 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
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",
],
@@ -94,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",
@@ -125,25 +124,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
onClick,
disabled,
copy,
loading = false,
...props
},
ref,
) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const iconSize = size === "xs" ? 12 : 14;
return (
<button
ref={ref}
type={type}
disabled={disabled || loading}
disabled={disabled}
className={cn(
buttonVariants({
variant,
@@ -160,8 +151,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
.writeText(copy)
.then(() => {
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}
@@ -169,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

@@ -2,6 +2,15 @@ import { ReactNode, forwardRef } from "react";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
// ConfirmDialog is the shared layout wrapper used by dialog-style window
// 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.
//
// Callers that mount the dialog inside its own Wails window pair this
// with useAutoSizeWindow by forwarding the returned ref onto the content
// wrapper so the window height tracks the rendered content.
type ConfirmDialogProps = {
children: ReactNode;
};
@@ -15,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,74 +0,0 @@
import { ReactNode } 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";
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export const ConfirmModal = ({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
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()}
>
<div className="flex flex-col gap-5 px-5">
<div className="flex flex-col gap-1 pl-1">
<DialogHeading align={"left"}>{title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
{description}
</DialogDescription>
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button variant={"secondary"} size={"xs2"} disabled={busy} onClick={onCancel}>
{resolvedCancel}
</Button>
<Button
autoFocus
variant={danger ? "danger" : "primary"}
size={"xs2"}
disabled={busy}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogActions>
</div>
</Dialog.Content>
</Dialog.Root>
);
};

View File

@@ -2,66 +2,53 @@ 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;
type OverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
exitAnimation?: boolean;
};
const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayProps>(
function DialogOverlay({ className, exitAnimation = false, ...props }, ref) {
return (
<DialogPrimitive.Overlay
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",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
{...props}
/>
);
},
);
const Overlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"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",
className,
)}
{...props}
/>
);
});
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
maxWidthClass?: string;
exitAnimation?: boolean;
};
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
function DialogContent(
{
className,
children,
showClose = true,
maxWidthClass = "max-w-md",
exitAnimation = false,
...props
},
{ className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
ref,
) {
const { t } = useTranslation();
return (
<DialogPrimitive.Portal>
<Overlay exitAnimation={exitAnimation}>
<Overlay>
<DialogPrimitive.Content
ref={ref}
className={cn(
"mx-auto relative z-[52] w-full outline-none ring-0",
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
"border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl rounded-lg",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
exitAnimation &&
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1",
"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-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
"duration-150 ease-out",
maxWidthClass,
className,
@@ -80,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

@@ -1,13 +1,21 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogActions wraps a vertical stack of Buttons inside a dialog surface.
// The wails-no-draggable class lets the user click the buttons even when
// the dialog window itself is draggable from any background region.
type DialogActionsProps = {
children: ReactNode;
className?: string;
};
export const DialogActions = ({ children, className }: DialogActionsProps) => (
<div className={cn("wails-no-draggable flex flex-col gap-3 w-full mx-auto", className)}>
<div
className={cn(
"wails-no-draggable flex flex-col gap-3 w-full mx-auto",
className,
)}
>
{children}
</div>
);

View File

@@ -1,26 +1,13 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogDescriptionProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogDescription = ({
children,
className,
align = "center",
}: DialogDescriptionProps) => (
<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

@@ -1,28 +1,16 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
// 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 DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
<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

@@ -7,7 +7,7 @@ import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
}
export const DaemonUnavailableOverlay = () => {
@@ -21,6 +21,10 @@ export const DaemonUnavailableOverlay = () => {
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
<div

View File

@@ -1,31 +1,62 @@
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";
// Knob to shift the centered main-window content up/down together.
export const CONTENT_VERTICAL_OFFSET = "-1.4rem";
export const contentTop = (base: string) => `calc(${base} + ${CONTENT_VERTICAL_OFFSET})`;
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"
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
}
style={{ top: contentTop("7.8rem") }}
>
<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,15 +1,6 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import {
forwardRef,
InputHTMLAttributes,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/Label";
@@ -22,7 +13,6 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
@@ -43,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: [
@@ -57,151 +43,6 @@ const inputVariants = cva("", {
},
});
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
const stepAttr = el.step === "" ? 1 : Number(el.step);
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min === "" ? -Infinity : Number(el.min);
const max = el.max === "" ? Infinity : Number(el.max);
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
return next;
}
function buildInputClassName(
opts: Readonly<{
variant: InputVariants["variant"];
hasCustomPrefix: boolean;
hasSuffix: boolean;
hasIcon: boolean;
readOnly?: boolean;
showStepper: boolean;
className?: string;
}>,
): string {
return cn(
inputVariants({ variant: opts.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",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!border-l-0 !rounded-l-none",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
"border",
opts.readOnly && "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
opts.showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
opts.className,
);
}
function InputAffix({
content,
error,
disabled,
className,
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
return (
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
disabled && "opacity-40",
className,
)}
>
{content}
</div>
);
}
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
disabled && "opacity-40",
)}
>
{icon}
</div>
);
}
function InputSuffixSlot({
suffix,
disabled,
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
disabled && "opacity-30",
)}
>
{suffix}
</div>
);
}
function NumberStepper({
error,
disabled,
onStep,
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => onStep(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => onStep(-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",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
);
}
function FieldMessage({ error, warning }: Readonly<{ error?: string; warning?: string }>) {
if (!error && !warning) return null;
return (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
@@ -212,7 +53,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
icon,
maxWidthClass = "",
error,
warning,
variant = "default",
prefixClassName,
showPasswordToggle = false,
@@ -222,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";
@@ -232,29 +71,28 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const reactId = useId();
const inputId = id ?? (label ? `input-${reactId}` : undefined);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
window.HTMLInputElement.prototype,
"value",
)?.set;
const next = computeNextStepValue(el, delta);
const stepAttr = el.step !== "" ? Number(el.step) : 1;
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min !== "" ? Number(el.min) : -Infinity;
const max = el.max !== "" ? Number(el.max) : Infinity;
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
@@ -265,21 +103,21 @@ 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>
) : null;
const onCopy = async () => {
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
@@ -288,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>
@@ -296,33 +134,37 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
const warningVariant = warning ? "warning" : variant;
const resolvedVariant = error ? "error" : warningVariant;
const inputClassName = buildInputClassName({
variant: resolvedVariant,
hasCustomPrefix: !!customPrefix,
hasSuffix: !!suffix,
hasIcon: !!icon,
readOnly: props.readOnly,
showStepper,
className,
});
return (
<div className="flex flex-col w-full min-w-0">
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<InputAffix
content={customPrefix}
error={error}
disabled={props.disabled}
className={prefixClassName}
/>
<div
className={cn(
inputVariants({
prefixSuffixVariant: error ? "error" : "default",
}),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
props.disabled && "opacity-40",
prefixClassName,
)}
>
{customPrefix}
</div>
)}
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
{icon && (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
props.disabled && "opacity-40",
)}
>
{icon}
</div>
)}
<div className="relative flex flex-grow min-w-0">
<input
@@ -330,17 +172,77 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type={inputType}
ref={setRefs}
{...props}
className={inputClassName}
className={cn(
inputVariants({
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",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-9",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,
)}
/>
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
{suffix && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
props.disabled && "opacity-30",
)}
>
{suffix}
</div>
)}
</div>
{showStepper && (
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
props.disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
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"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
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",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
)}
</div>
<FieldMessage error={error} warning={warning} />
{error && (
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
{error}
</span>
)}
</div>
);
});

View File

@@ -7,39 +7,49 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
shortcut?: ReactNode;
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
export const SearchInput = forwardRef<HTMLInputElement, Props>(
function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
"flex items-center gap-2 px-1 h-10",
disabled && "opacity-50",
)}
/>
{shortcut && (
<span
>
<SearchIcon
size={iconSize}
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
)}
>
{shortcut}
</span>
)}
</div>
);
});
/>
{shortcut && (
<span
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
>
{shortcut}
</span>
)}
</div>
);
},
);

View File

@@ -31,27 +31,39 @@ export default function FancyToggleSwitch({
labelClassName,
textWrapperClassName = "max-w-lg",
}: Readonly<Props>) {
const childrenRef = React.useRef<HTMLDivElement>(null);
if (loading) {
// Match the global SkeletonTheme in app.tsx (#25282d base /
// #33373e highlight) so the loading row blends in with
// SettingsSkeleton. box-decoration-clone gives every wrapped line
// of text its own rounded corners instead of just the first/last.
const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div className={cn("inline-block text-left w-full", className)} aria-busy>
<div
className={cn("inline-block text-left w-full", className)}
aria-busy
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
<span className={shimmer}>{label}</span>
</Label>
<HelpText margin={false}>
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
<span
className={cn(
shimmer,
"text-[0.6rem] leading-relaxed",
)}
>
{helpText}
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
className={
"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"
}
/>
</div>
</div>
@@ -59,19 +71,16 @@ export default function FancyToggleSwitch({
);
}
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
const handleToggle = () => {
if (disabled) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled || fromChildren(event.target)) return;
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onChange(!value);
handleToggle();
}
};
@@ -99,7 +108,7 @@ export default function FancyToggleSwitch({
</div>
</div>
{children && value ? (
<div className="mt-4" ref={childrenRef}>
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}

View File

@@ -1,10 +1,11 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, ReactNode, useContext, useId, useMemo } from "react";
import { createContext, ReactNode, useContext, useId } from "react";
import { cn } from "@/lib/cn";
type SwitchItemGroupContextValue = {
value: string;
layoutId: string;
disabled: boolean;
};
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
@@ -33,10 +34,9 @@ export const SwitchItemGroup = ({
disabled = false,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={contextValue}>
<SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}>
<RadioGroup.Root
value={value}
onValueChange={onChange}

View File

@@ -27,7 +27,11 @@ export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
}
return (
<LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}>
<LabelPrimitive.Root
ref={ref as Ref<HTMLLabelElement>}
className={classes}
{...props}
>
{children}
</LabelPrimitive.Root>
);

View File

@@ -9,12 +9,18 @@ import {
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { errorDialog } from "@/lib/dialogs.ts";
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
import type { State as UpdateState } from "@bindings/updater/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { formatErrorMessage } from "@/lib/errors";
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
// (for Trigger) handled by the install window's polling-grace branch; a
// second popup on top of those is pure noise. Every Update RPC routes
// through the shared gRPC conn, so the Unavailable code is the marker.
const isDaemonUnavailable = (e: unknown): boolean => {
const msg = e instanceof Error ? e.message : String(e);
return msg.includes("code = Unavailable");
@@ -75,6 +81,9 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
};
}, []);
// Force-install branch: daemon's progress_window:show flipped installing
// to true while the UI was idle. Open the install window so the user
// sees the progress UI without having to click anything.
const prevInstallingRef = useRef(false);
useEffect(() => {
if (state.installing && !prevInstallingRef.current) {
@@ -83,11 +92,19 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
prevInstallingRef.current = state.installing;
}, [state.installing, state.version]);
// Enforced user-driven branch: kick Trigger() in the background, then
// hand off to the install window. The window owns the polling loop and
// the final Quit() — this provider just fires the trigger.
const triggerUpdate = useCallback(() => {
setUpdating(true);
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
UpdateSvc.Trigger()
.catch(async (e) => {
// The daemon may already be down (force-install branch raced
// us). The install window's polling loop handles that case.
// Anything else is a real failure — close the install window
// (otherwise it spins forever on a daemon that won't ever
// produce a result) and surface the error.
if (isDaemonUnavailable(e)) return;
WindowManager.CloseInstallProgress().catch(console.error);
await errorDialog({
@@ -110,5 +127,9 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
[state, triggerUpdate, updating],
);
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
return (
<ClientVersionContext.Provider value={value}>
{children}
</ClientVersionContext.Provider>
);
};

View File

@@ -1,8 +1,18 @@
import { createContext, useContext, useRef, useState, type ReactNode } from "react";
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
} from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatErrorMessage } from "@/lib/errors.ts";
import { useProfile } from "@/contexts/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
@@ -37,71 +47,15 @@ const sleep = (ms: number, signal: AbortSignal) =>
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
const throwIfAborted = (signal: AbortSignal) => {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
};
const setLogLevelBestEffort = async (level: string) => {
try {
await DebugSvc.SetLogLevel({ level });
} catch {
// empty
}
};
type LevelState = { original: string; raised: boolean };
const runTracePhase = async (
signal: AbortSignal,
level: LevelState,
setStage: (s: DebugStage) => void,
target: { profileName: string; username: string },
traceMinutes: number,
) => {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) level.original = cur.level;
} catch {
// empty
}
throwIfAborted(signal);
await DebugSvc.SetLogLevel({ level: "trace" });
level.raised = true;
throwIfAborted(signal);
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// empty
}
throwIfAborted(signal);
await ConnectionSvc.Up(target);
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: level.original });
level.raised = false;
} catch {
// empty
}
};
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(false);
const [trace, setTrace] = useState(true);
const [traceMinutes, setTraceMinutes] = useState(1);
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
const [lastBundlePath, setLastBundlePath] = useState<string>("");
@@ -121,24 +75,66 @@ const useDebugBundle = () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const checkAbort = () => {
if (signal.aborted)
throw new DOMException("aborted", "AbortError");
};
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
const level: LevelState = { original: "info", raised: false };
let originalLevel = "info";
let raisedLevel = false;
try {
if (trace) {
await runTracePhase(
signal,
level,
setStage,
{ profileName: activeProfile, username },
traceMinutes,
);
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) originalLevel = cur.level;
} catch {
// best effort
}
checkAbort();
await DebugSvc.SetLogLevel({ level: "trace" });
raisedLevel = true;
checkAbort();
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// already down
}
checkAbort();
await ConnectionSvc.Up({
profileName: activeProfile,
username,
});
const totalSec =
Math.max(1, Math.min(30, traceMinutes)) * 60;
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({
kind: "capturing",
remainingSec: remaining,
totalSec,
});
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
raisedLevel = false;
} catch {
// restore is best-effort
}
}
throwIfAborted(signal);
checkAbort();
setStage({ kind: "bundling" });
const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
const logFileCount = trace
? TRACE_LOG_FILE_COUNT
: PLAIN_LOG_FILE_COUNT;
if (uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
@@ -147,7 +143,7 @@ const useDebugBundle = () => {
uploadUrl,
logFileCount,
});
throwIfAborted(signal);
checkAbort();
if (result.path) setLastBundlePath(result.path);
setStage({
kind: "done",
@@ -156,7 +152,13 @@ const useDebugBundle = () => {
});
} catch (e) {
if (isAbort(e)) {
if (level.raised) await setLogLevelBestEffort(level.original);
if (raisedLevel) {
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
} catch {
// best effort
}
}
setStage({ kind: "idle" });
return;
}
@@ -172,9 +174,7 @@ const useDebugBundle = () => {
const openBundleDir = () => {
if (!lastBundlePath) return;
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
console.error("[DebugBundleContext] reveal failed", err),
);
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
};
return {
@@ -204,13 +204,19 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
};
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
throw new Error(
"useDebugBundleContext must be used inside DebugBundleProvider",
);
}
return ctx;
};

View File

@@ -1,68 +0,0 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
};
type DialogContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: Readonly<{ 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;
});
}, []);
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
return (
<DialogContext.Provider value={value}>
{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, useMemo, useState, type ReactNode } from "react";
import { createContext, useContext, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
export type NavSection = "peers" | "networks" | "exitNode";
type NavSectionContextValue = {
section: NavSection;
@@ -12,13 +12,18 @@ const NavSectionContext = createContext<NavSectionContextValue | null>(null);
export const useNavSection = (): NavSectionContextValue => {
const ctx = useContext(NavSectionContext);
if (!ctx) {
throw new Error("useNavSection must be used inside NavSectionProvider");
throw new Error(
"useNavSection must be used inside NavSectionProvider",
);
}
return ctx;
};
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
const [section, setSection] = useState<NavSection>("peers");
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
return (
<NavSectionContext.Provider value={{ section, setSection }}>
{children}
</NavSectionContext.Provider>
);
};

View File

@@ -12,9 +12,10 @@ import { Networks as NetworksSvc } from "@bindings/services";
import type { Network } from "@bindings/services/models.js";
import { useStatus } from "@/contexts/StatusContext";
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
export const isExitNode = (range: string): boolean =>
// A range is treated as an exit-node candidate when any of its CIDRs is a
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
// comma-joined range string for one peer.
export const isDefaultRoute = (range: string): boolean =>
range.split(",").some((part) => {
const trimmed = part.trim();
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
@@ -44,61 +45,33 @@ export const useNetworks = () => {
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
const [routes, setRoutes] = useState<Network[]>([]);
// Optimistic overrides: id → expected `selected` value. Applied on top of
// the server-side `routes` so toggles paint instantly. Entries are cleared
// either when the next server snapshot agrees (success path) or when the
// RPC throws (rollback). Linear-style optimistic mutation tracking.
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
// Mirror of `pending` for use inside async callbacks without re-binding
// them on every change.
const pendingRef = useRef(pending);
useEffect(() => {
pendingRef.current = pending;
}, [pending]);
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
const STUCK_OVERRIDE_MS = 4000;
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((id: string) => {
const tid = timersRef.current.get(id);
if (tid !== undefined) {
clearTimeout(tid);
timersRef.current.delete(id);
}
const setPendingFor = useCallback((updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
}, []);
const clearPendingFor = useCallback(
(ids: string[]) => {
for (const id of ids) clearTimer(id);
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
},
[clearTimer],
);
const setPendingFor = useCallback(
(updates: Array<[string, boolean]>) => {
setPending((prev) => {
const next = new Map(prev);
for (const [id, sel] of updates) next.set(id, sel);
return next;
});
for (const [id] of updates) {
clearTimer(id);
timersRef.current.set(
id,
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
);
}
},
[clearTimer, clearPendingFor],
);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const tid of timers.values()) clearTimeout(tid);
timers.clear();
};
const clearPendingFor = useCallback((ids: string[]) => {
setPending((prev) => {
if (ids.every((id) => !prev.has(id))) return prev;
const next = new Map(prev);
for (const id of ids) next.delete(id);
return next;
});
}, []);
const refresh = useCallback(async () => {
@@ -110,11 +83,19 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
// The daemon bumps networksRevision whenever the routed-network set or a
// selection changes (from any surface) and pushes it on the status stream.
// Refetch on every bump so the list stays live without polling — and on
// mount, since the revision is already defined by the time this provider
// renders (StatusProvider only mounts children once the daemon is reachable).
const networksRevision = status?.networksRevision;
useEffect(() => {
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
void refresh();
}, [refresh, networksRevision]);
// When the server snapshot agrees with a pending optimistic value, the
// mutation is confirmed — drop the override so the row tracks the server
// again. Runs whenever routes change.
useEffect(() => {
if (pendingRef.current.size === 0) return;
const confirmed: string[] = [];
@@ -135,10 +116,13 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
}
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
// Don't clear pending here — let the revision-driven refresh
// confirm via the snapshot-match effect. That avoids a flash
// back to old state if the refresh races the RPC return.
await refresh();
} catch (e) {
console.error(e);
// Roll back to the last server-observed value for each id.
setPending((prev) => {
const next = new Map(prev);
for (const [id] of rollback) next.delete(id);
@@ -159,6 +143,9 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor],
);
// Batch toggle for the bottom-bar select-all switch. The daemon's
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
// per-ID calls — one round-trip + one refresh.
const setNetworksSelected = useCallback(
async (ids: string[], selected: boolean) => {
if (ids.length === 0) return;
@@ -173,7 +160,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
[mutate, setPendingFor, routes],
);
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
// Exit nodes are mutually exclusive, but the daemon enforces that now —
// selecting one deselects the other exit nodes. Append so activating an
// exit node doesn't wipe the user's network-route selections. We also
// mirror that mutual-exclusion locally so the optimistic paint matches
// the daemon's eventual state.
const toggleExitNode = useCallback(
async (id: string, selected: boolean) => {
const target = !selected;
@@ -181,7 +172,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
const rollback: Array<[string, boolean]> = [[id, selected]];
if (target) {
for (const r of routes) {
if (r.id !== id && isExitNode(r.range) && r.selected) {
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
updates.push([r.id, false]);
rollback.push([r.id, true]);
}
@@ -194,6 +185,9 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
);
const value = useMemo<NetworksContextValue>(() => {
// Apply pending overrides on top of the server snapshot. The override
// map is usually empty or tiny (one entry per in-flight toggle), so
// the per-route lookup is effectively free.
const effective =
pending.size === 0
? routes
@@ -203,8 +197,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
? r
: { ...r, selected: override };
});
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
const exitNodes = effective.filter((r) => isExitNode(r.range));
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
return {
routes: effective,

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { createContext, useContext, useState, type ReactNode } from "react";
import type { PeerStatus } from "@bindings/services/models.js";
type PeerDetailContextValue = {
@@ -11,13 +11,18 @@ const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
export const usePeerDetail = (): PeerDetailContextValue => {
const ctx = useContext(PeerDetailContext);
if (!ctx) {
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
throw new Error(
"usePeerDetail must be used inside PeerDetailProvider",
);
}
return ctx;
};
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
const [selected, setSelected] = useState<PeerStatus | null>(null);
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
return (
<PeerDetailContext.Provider value={{ selected, setSelected }}>
{children}
</PeerDetailContext.Provider>
);
};

View File

@@ -3,15 +3,19 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Events } from "@wailsio/runtime";
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
import { errorDialog } from "@/lib/dialogs.ts";
import {
Connection,
ProfileSwitcher,
Profiles as ProfilesSvc,
} from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { formatErrorMessage } from "@/lib/errors";
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
@@ -54,7 +58,10 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
setActiveProfile(active.profileName || "default");
setProfiles(list);
} catch (e) {
// Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here.
// Daemon-down is already surfaced globally by
// DaemonUnavailableOverlay; a second popup on top of it is
// pure noise. Every profile RPC routes through the same gRPC
// conn, so the Unavailable code is the reliable marker.
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("code = Unavailable")) {
return;
@@ -69,14 +76,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
}, []);
useEffect(() => {
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
void refresh();
}, [refresh]);
useEffect(() => {
// The tray and other windows drive switches through the same
// ProfileSwitcher.SwitchActive RPC, which emits this event on success.
// Without the subscription, a tray-initiated switch leaves this
// window painting the old activeProfile until the next mount.
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
refresh().catch((err: unknown) =>
console.error("[ProfileContext] refresh failed", err),
);
void refresh();
});
return () => {
off();
@@ -115,30 +124,21 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
const value = useMemo<ProfileContextValue>(
() => ({
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}),
[
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
],
return (
<ProfileContext.Provider
value={{
username,
activeProfile,
profiles,
loaded,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}}
>
{children}
</ProfileContext.Provider>
);
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
};

View File

@@ -3,22 +3,20 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { errorDialog } from "@/lib/dialogs.ts";
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
export type AutostartState = { supported: boolean; enabled: boolean };
type SettingsContextValue = {
@@ -49,7 +47,9 @@ export const useSettings = () => {
export const useAutostartSetting = () => {
const ctx = useContext(AutostartContext);
if (!ctx) {
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
throw new Error(
"useAutostartSetting must be used inside AutostartSettingsProvider",
);
}
return ctx;
};
@@ -97,7 +97,10 @@ const useSettingsState = () => {
const save = useCallback(
async (next: Config) => {
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
// The daemon masks an existing PSK as "**********" in GetConfig.
// Sending the mask back round-trips it into the saved config and
// wgtypes.ParseKey fails on the next connect. Drop the mask so
// unrelated toggles don't corrupt the stored PSK.
const { preSharedKey, ...rest } = next;
try {
await SettingsSvc.SetConfig({
@@ -123,7 +126,7 @@ const useSettingsState = () => {
const next = { ...c, [k]: v };
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
save(next).catch(logSaveError);
void save(next);
}, SAVE_DEBOUNCE_MS);
return next;
});
@@ -172,22 +175,29 @@ const useSettingsState = () => {
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const { config, guiVersion, setField, saveField, saveFields, saveNow } =
useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
if (!value) {
return (
<div className={"flex-1 min-h-0 overflow-y-auto py-8 px-7"}>
return (
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SettingsSkeleton />
</div>
);
}
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
) : (
<SettingsContext.Provider
value={{
config,
guiVersion,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
)}
</div>
);
};
export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => {
@@ -222,10 +232,9 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
}
}, []);
const value = useMemo<AutostartContextValue>(
() => ({ autostart, setAutostartEnabled }),
[autostart, setAutostartEnabled],
return (
<AutostartContext.Provider value={{ autostart, setAutostartEnabled }}>
{children}
</AutostartContext.Provider>
);
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
};

View File

@@ -1,19 +1,21 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { Events } from "@wailsio/runtime";
import { DaemonFeed } from "@bindings/services";
import { Status } from "@bindings/services/models.js";
import type { Status } from "@bindings/services/models.js";
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
const EVENT_STATUS = "netbird:status";
// StatusContext is the single subscription point for the daemon status
// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener,
// and the synthetic DaemonUnavailable handling. The provider also renders
// the DaemonUnavailableOverlay so every layout that mounts it inherits the
// same blocker without re-importing the component.
//
// Boolean flags consumers should prefer over hand-rolled checks:
// - isReady first DaemonFeed.Get has resolved
// - isDaemonUnavailable ready and status === "DaemonUnavailable"
// - isDaemonAvailable ready and status !== "DaemonUnavailable"
type StatusContextValue = {
status: Status | null;
error: string | null;
@@ -43,14 +45,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
setStatus(s);
setError(null);
} catch (e) {
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
// DaemonFeed.Get returns a gRPC error when the socket itself is
// unreachable (daemon not running, missing socket, etc.); only
// the streaming path synthesizes a DaemonUnavailable status.
// Synthesize one here too so the overlay paints on cold start
// without a daemon — otherwise the whole UI stays blank since
// `isReady` would never flip and StatusProvider's short-circuit
// wouldn't render either children or the overlay.
setStatus({ status: "DaemonUnavailable" } as Status);
setError(String(e));
}
}, []);
useEffect(() => {
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
void refresh();
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
@@ -64,20 +72,23 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
const isDaemonAvailable = isReady && !isDaemonUnavailable;
const value = useMemo<StatusContextValue>(
() => ({
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}),
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable],
);
// Don't mount children until the first DaemonFeed.Get has resolved and the
// daemon is reachable. Consumers (ProfileContext, SettingsContext, …)
// can then assume any daemon RPC they make at mount will reach the
// socket — no per-context availability gating. When the daemon flips
// back to unavailable the children unmount and remount fresh once it
// returns.
return (
<StatusContext.Provider value={value}>
<StatusContext.Provider
value={{
status,
error,
refresh,
isReady,
isDaemonUnavailable,
isDaemonAvailable,
}}
>
{isDaemonAvailable && children}
<DaemonUnavailableOverlay />
</StatusContext.Provider>

View File

@@ -3,7 +3,6 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -14,8 +13,13 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
export type ViewMode = "default" | "advanced";
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
// Window widths per view. Height stays at whatever the window was first
// created with — we deliberately don't pass a fixed height to
// Window.SetSize because Wails' macOS implementation interprets it as the
// outer frame (windowSetSize → setFrame:), while the initial creation
// uses initWithContentRect:. The two differ by one title-bar height
// (~28px), so re-asserting 640 here would chop ~28px off the content
// area on the first switch and visually shift everything inside.
export const VIEW_WIDTH: Record<ViewMode, number> = {
default: 380,
advanced: 900,
@@ -29,12 +33,18 @@ type ViewModeContextValue = {
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
const [mode, setMode] = useState<ViewMode>("default");
const [viewMode, setMode] = useState<ViewMode>("default");
// Mirror of viewMode for dedup inside the async setViewMode without
// adding the state to the callback's dep array (which would re-create
// the callback on every change).
const modeRef = useRef<ViewMode>("default");
// Hydrate from the persisted preference. The Go side has already sized
// the main window to match (see main.go), so this only catches the
// React state and dropdown checkmark up — no resize is triggered here.
useEffect(() => {
let cancelled = false;
Preferences.Get()
void Preferences.Get()
.then((prefs) => {
if (cancelled) return;
const saved = prefs?.viewMode as ViewMode | undefined;
@@ -49,30 +59,31 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
};
}, []);
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
// Resize the window BEFORE flipping React state — otherwise the new
// layout (e.g., advanced-mode right panel mounting) paints into a
// window that hasn't grown yet, causing a brief flex-overflow that
// wobbles the connect toggle's position. Cost: one IPC roundtrip
// (~30ms) before the dropdown checkmark updates.
const setViewMode = useCallback((mode: ViewMode) => {
if (modeRef.current === mode) return;
modeRef.current = mode;
(async () => {
void (async () => {
// Reuse the live frame height instead of asserting a
// constant — keeps content area stable across switches
// (see VIEW_WIDTH comment above).
const size = await Window.Size().catch(() => null);
const width = VIEW_WIDTH[mode];
const height = size?.height ?? 640;
await Window.SetSize(width, height).catch(() => {});
setMode(mode);
const pref =
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
Preferences.SetViewMode(pref).catch((err: unknown) =>
console.error("[ViewModeContext] SetViewMode failed", err),
);
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {});
})();
}, []);
const value = useMemo<ViewModeContextValue>(
() => ({ viewMode: mode, setViewMode }),
[mode, setViewMode],
return (
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
{children}
</ViewModeContext.Provider>
);
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
};
export const useViewMode = () => {

View File

@@ -1,15 +1,15 @@
@font-face {
font-family: "Inter Variable";
font-style: normal;
font-weight: 100 900;
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
font-family: "Inter Variable";
font-style: normal;
font-weight: 100 900;
src: url("./assets/fonts/inter-variable.ttf") format("truetype");
}
@font-face {
font-family: "JetBrains Mono Variable";
font-style: normal;
font-weight: 100 800;
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
font-family: "JetBrains Mono Variable";
font-style: normal;
font-weight: 100 800;
src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
}
@tailwind base;
@@ -19,8 +19,8 @@
html,
body,
#root {
height: 100%;
overflow: hidden;
height: 100%;
overflow: hidden;
}
/*
@@ -32,14 +32,14 @@ body,
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
*/
body {
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
.wails-draggable {
--wails-draggable: drag;
cursor: default;
--wails-draggable: drag;
cursor: default;
}
.wails-no-draggable {
--wails-draggable: no-drag;
--wails-draggable: no-drag;
}

View File

@@ -1,11 +1,28 @@
import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
import { isLinux } from "@/lib/platform";
// Sizes the current Wails window to the measured content height (keeping `width`),
// then shows it. Re-applies on content resize and language change.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
// useAutoSizeWindow resizes the current Wails window so its height matches
// the measured height of the content element the returned ref is attached
// to. Width stays fixed (Wails has no "fit-content-width" notion and the
// dialog-style session windows want a stable horizontal footprint).
//
// On first measurement the hook also calls Window.Show()/Focus() — the
// Go-side opens the window with Hidden: true so the user never sees the
// initial placeholder size snap to the measured size. Subsequent
// measurements (content changes after mount) only adjust the size.
//
// Re-measures via ResizeObserver so adding/removing content (e.g. the
// 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
// theory catches the same reflow when translated strings replace each
// other (DE/HU strings often wrap to more lines than EN), but in practice
// 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.
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
const el = ref.current;
@@ -13,31 +30,38 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
let shown = false;
let raf1 = 0;
let raf2 = 0;
const showOnce = () => {
if (shown) return;
shown = true;
Window.Show().catch(() => {});
Window.Focus().catch(() => {});
};
const apply = async () => {
if (!ready) return;
const apply = () => {
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
try {
// Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
const frame = await Window.Size();
const targetH = h + Math.max(0, frame.height - window.innerHeight);
// Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead.
if (isLinux()) {
await Window.SetMinSize(width, targetH);
await Window.SetMaxSize(width, targetH);
}
await Window.SetSize(width, targetH);
showOnce();
} catch {
// window gone / not ready — ignore
}
// Wails Window.SetSize takes the *frame* size on every platform
// (Windows: SetWindowPos, macOS: setFrame:, Linux: GTK frame).
// The OS title bar lives inside the frame, so we have to add the
// chrome height before calling SetSize, or the title bar eats
// pixels from the bottom and the rendered content gets clipped.
//
// window.outerHeight / window.innerHeight are useless here:
// WebView2 (and WKWebView) report the WebView's own outer == inner
// because the WebView itself has no chrome — the OS title bar is
// outside the WebView's window object entirely. The only way to
// recover the chrome height is to compare the OS frame height
// (Wails-side Window.Size()) against the WebView viewport
// (window.innerHeight).
void Window.Size()
.then((frame) => {
const chrome = Math.max(0, frame.height - window.innerHeight);
return Window.SetSize(width, h + chrome);
})
.then(() => {
if (shown) return;
shown = true;
void Window.Show().catch(() => {});
void Window.Focus().catch(() => {});
})
.catch(() => {});
};
// Double rAF: first frame lands after React commits the new
// translated strings, second frame lands after the browser has
// recomputed layout, so apply() sees the final box.
const scheduleApply = () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
@@ -55,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,15 +1,22 @@
import { useEffect } from "react";
import { isMacOS } from "@/lib/platform";
export type Shortcut = {
key: string;
cmd?: boolean;
key: string; // e.g. "k", "Escape", "/"
cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
shift?: boolean;
alt?: boolean;
// When true (default), preventDefault is called on a match.
preventDefault?: boolean;
};
export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
// Listens for a keyboard shortcut on the window and invokes `callback` on
// match. Disable conditionally via `enabled` to avoid stealing keys while a
// dialog/panel is in the foreground.
export const useKeyboardShortcut = (
shortcut: Shortcut,
callback: () => void,
enabled = true,
) => {
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
@@ -21,8 +28,8 @@ export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, en
if (shortcut.preventDefault !== false) e.preventDefault();
callback();
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
shortcut.key,
shortcut.cmd,
@@ -34,13 +41,16 @@ export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, en
]);
};
// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
export const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
export const formatShortcut = (shortcut: Shortcut): string => {
// navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
const mac = isMacOS();
const parts: string[] = [];
if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
return parts.join(mac ? "" : "+");
return parts.join(isMac ? "" : "+");
};

View File

@@ -1,78 +1,55 @@
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";
// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
export const URL_PATTERN = new RegExp(
String.raw`^(https?:\/\/)?` +
String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
String.raw`((\d{1,3}\.){3}\d{1,3}))` +
String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
String.raw`(\#[-a-z\d_]*)?$`,
"i",
);
export function normalizeManagementUrl(input: string): string {
function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function isValidManagementUrl(input: string): boolean {
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}))" +
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
"(\\?[;&a-z\\d%_.~+=-]*)?" +
"(\\#[-a-z\\d_]*)?$",
"i",
);
function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
export function isCloudManagementUrl(url: string): boolean {
if (!url || url.trim() === "") return true;
return url === CLOUD_MANAGEMENT_URL;
}
// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat 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 [modeState, setModeState] = useState<ManagementMode>(modeFromUrl(config.managementUrl));
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
);
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
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));
@@ -81,22 +58,34 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
useEffect(() => {
setUnreachable(false);
}, [url, modeState]);
const setMode = async (next: ManagementMode) => {
if (next === ManagementMode.Cloud && config.managementUrl !== CLOUD_MANAGEMENT_URL) {
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);
saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
console.error("save managementUrl failed", err),
);
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 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);
@@ -104,28 +93,18 @@ export function useManagementUrl() {
const normalizedUrl = normalizeManagementUrl(url);
const urlValid = isValidManagementUrl(url);
const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const targetUrl =
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const dirty = targetUrl !== config.managementUrl;
const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const showError =
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = async () => {
if (modeState === 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: modeState,
mode,
setMode,
url,
setUrl,
@@ -133,7 +112,5 @@ export function useManagementUrl() {
showError,
canSave,
save,
checking,
unreachable,
};
}

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