mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-04 15:09:54 +00:00
Compare commits
32 Commits
ui-tray-li
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6f6d88cb | ||
|
|
e298747203 | ||
|
|
5993ec6e43 | ||
|
|
cba4a8a63b | ||
|
|
93e068f753 | ||
|
|
94065a8058 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 | ||
|
|
d7263a6be9 | ||
|
|
64199209cf | ||
|
|
166c6118e2 | ||
|
|
3e61ccb162 | ||
|
|
a48c20d8d8 | ||
|
|
2b57a7d43b | ||
|
|
1710868a09 | ||
|
|
7798b7cf14 | ||
|
|
179966b000 | ||
|
|
48265a0143 | ||
|
|
fa1e241aea | ||
|
|
9a76507b14 | ||
|
|
c3a0c1beeb | ||
|
|
45095818bc | ||
|
|
1f74ee9c78 | ||
|
|
562a538e91 | ||
|
|
50b26a21fd | ||
|
|
fefd0da7bf | ||
|
|
d1f3d88f0d | ||
|
|
a71ef1bab0 | ||
|
|
16743c4ce5 | ||
|
|
3c0a9c314b | ||
|
|
e7c9182ff9 |
8
.github/workflows/golang-test-darwin.yml
vendored
8
.github/workflows/golang-test-darwin.yml
vendored
@@ -53,5 +53,11 @@ 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 -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 -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)
|
||||
|
||||
- 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
|
||||
|
||||
60
.github/workflows/golang-test-linux.yml
vendored
60
.github/workflows/golang-test-linux.yml
vendored
@@ -166,7 +166,15 @@ 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 -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)
|
||||
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
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
@@ -284,9 +292,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test ${{ matrix.raceFlag }} \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-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]
|
||||
@@ -334,7 +350,15 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test -timeout 10m -p 1 ./proxy/...
|
||||
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
|
||||
|
||||
test_signal:
|
||||
name: "Signal / Unit"
|
||||
@@ -385,9 +409,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-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]
|
||||
@@ -453,10 +485,18 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
-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]
|
||||
@@ -695,6 +735,14 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
-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
|
||||
|
||||
39
.github/workflows/proto-version-check.yml
vendored
39
.github/workflows/proto-version-check.yml
vendored
@@ -20,15 +20,30 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
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');
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
|
||||
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
// 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 baseSha = context.payload.pull_request.base.sha;
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
|
||||
@@ -55,20 +70,22 @@ jobs:
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of modifiedPbFiles) {
|
||||
for (const file of changedPbFiles) {
|
||||
const [base, head] = await Promise.all([
|
||||
getVersionHeader(file.filename, baseSha),
|
||||
getVersionHeader(file.filename, headSha),
|
||||
getVersionHeader(file.basePath, baseSha),
|
||||
getVersionHeader(file.headPath, headSha),
|
||||
]);
|
||||
if (!base.ok || !head.ok) {
|
||||
core.warning(
|
||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
`Skipping ${file.headPath}: 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.filename,
|
||||
file: file.basePath === file.headPath
|
||||
? file.headPath
|
||||
: `${file.basePath} → ${file.headPath}`,
|
||||
base: base.lines,
|
||||
head: head.lines,
|
||||
});
|
||||
|
||||
@@ -196,6 +196,7 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
@@ -210,6 +211,7 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
|
||||
@@ -70,6 +70,8 @@ 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:
|
||||
@@ -80,7 +82,7 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
@@ -92,6 +94,8 @@ nfpms:
|
||||
- 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:
|
||||
@@ -102,7 +106,7 @@ nfpms:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
|
||||
@@ -12,7 +12,13 @@ var (
|
||||
Short: "Print the NetBird's client application version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
cmd.Println(version.NetbirdVersion())
|
||||
out := version.NetbirdVersion()
|
||||
if version.IsDevelopmentVersion(out) {
|
||||
if commit := version.NetbirdCommit(); commit != "" {
|
||||
out += "-" + commit
|
||||
}
|
||||
}
|
||||
cmd.Println(out)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -362,6 +362,10 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -355,6 +356,11 @@ 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)
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -56,6 +55,7 @@ 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"
|
||||
@@ -148,6 +148,10 @@ 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.
|
||||
@@ -226,10 +230,15 @@ type Engine struct {
|
||||
|
||||
afpacketCapture *capture.AFPacketCapture
|
||||
|
||||
// Sync response persistence (protected by syncRespMux)
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
// 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
|
||||
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// auto-update
|
||||
@@ -306,6 +315,7 @@ 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
|
||||
@@ -944,19 +954,18 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
}
|
||||
|
||||
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// Read the storage-enabled flag under the syncRespMux too.
|
||||
// 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()
|
||||
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())
|
||||
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()
|
||||
|
||||
// only apply new changes and ignore old ones
|
||||
if err := e.updateNetworkMap(nm); err != nil {
|
||||
@@ -1094,6 +1103,7 @@ 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)
|
||||
|
||||
@@ -1844,6 +1854,18 @@ 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) {
|
||||
@@ -2186,45 +2208,42 @@ func (e *Engine) stopDNSServer() {
|
||||
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
||||
}
|
||||
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
// 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).
|
||||
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
||||
e.syncRespMux.Lock()
|
||||
defer e.syncRespMux.Unlock()
|
||||
|
||||
if enabled == e.persistSyncResponse {
|
||||
if enabled == (e.syncStore != nil) {
|
||||
return
|
||||
}
|
||||
e.persistSyncResponse = enabled
|
||||
log.Debugf("Sync response persistence is set to %t", enabled)
|
||||
|
||||
if !enabled {
|
||||
e.latestSyncResponse = nil
|
||||
if err := e.syncStore.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response: %v", err)
|
||||
}
|
||||
e.syncStore = nil
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
enabled := e.persistSyncResponse
|
||||
latest := e.latestSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
defer e.syncRespMux.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
if e.syncStore == nil {
|
||||
return nil, errors.New("sync response persistence is disabled")
|
||||
}
|
||||
|
||||
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
|
||||
//nolint:nilnil
|
||||
return e.syncStore.Get()
|
||||
}
|
||||
|
||||
// GetWgAddr returns the wireguard address
|
||||
@@ -2260,7 +2279,7 @@ func (e *Engine) updateDNSForwarder(
|
||||
enabled bool,
|
||||
fwdEntries []*dnsfwd.ForwarderEntry,
|
||||
) {
|
||||
if e.config.DisableServerRoutes {
|
||||
if e.config.DisableServerRoutes || e.config.BlockInbound {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -11,7 +13,7 @@ var (
|
||||
)
|
||||
|
||||
func IsSupported(agentVersion string) bool {
|
||||
if agentVersion == "development" {
|
||||
if nbversion.IsDevelopmentVersion(agentVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ type LocalPeerState struct {
|
||||
PubKey string
|
||||
KernelInterface bool
|
||||
FQDN string
|
||||
WgPort int
|
||||
Routes map[string]struct{}
|
||||
}
|
||||
|
||||
@@ -334,8 +335,12 @@ 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. 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. 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.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
@@ -348,6 +353,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -1518,6 +1528,7 @@ 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)
|
||||
|
||||
@@ -90,6 +90,28 @@ 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"
|
||||
|
||||
99
client/internal/syncstore/disk.go
Normal file
99
client/internal/syncstore/disk.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
9
client/internal/syncstore/factory_ios.go
Normal file
9
client/internal/syncstore/factory_ios.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//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)
|
||||
}
|
||||
9
client/internal/syncstore/factory_other.go
Normal file
9
client/internal/syncstore/factory_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//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()
|
||||
}
|
||||
56
client/internal/syncstore/memory.go
Normal file
56
client/internal/syncstore/memory.go
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
29
client/internal/syncstore/syncstore.go
Normal file
29
client/internal/syncstore/syncstore.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
|
||||
const (
|
||||
latestVersion = "latest"
|
||||
// this version will be ignored
|
||||
developmentVersion = "development"
|
||||
)
|
||||
|
||||
var errNoUpdateState = errors.New("no update state found")
|
||||
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
|
||||
}
|
||||
|
||||
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
|
||||
if m.currentVersion == developmentVersion {
|
||||
if version.IsDevelopmentVersion(m.currentVersion) {
|
||||
log.Debugf("skipping auto-update, running development version")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1638,6 +1638,7 @@ 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
|
||||
}
|
||||
@@ -1728,6 +1729,13 @@ 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"`
|
||||
@@ -6739,7 +6747,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"sshHostKey\x18\x13 \x01(\fR\n" +
|
||||
"sshHostKey\x12\x12\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
|
||||
"\x0eLocalPeerState\x12\x0e\n" +
|
||||
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
|
||||
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
|
||||
@@ -6748,7 +6756,8 @@ 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\"S\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
|
||||
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
|
||||
"\vSignalState\x12\x10\n" +
|
||||
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
|
||||
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
|
||||
|
||||
@@ -384,6 +384,7 @@ message LocalPeerState {
|
||||
bool rosenpassPermissive = 6;
|
||||
repeated string networks = 7;
|
||||
string ipv6 = 8;
|
||||
int32 wgPort = 9;
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
#!/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"))
|
||||
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.1
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
|
||||
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
|
||||
cd "$old_pwd"
|
||||
|
||||
@@ -793,6 +793,22 @@ 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)
|
||||
@@ -1848,6 +1864,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -1869,9 +1887,32 @@ 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()
|
||||
|
||||
@@ -147,6 +147,7 @@ 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"`
|
||||
@@ -196,6 +197,7 @@ 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(),
|
||||
@@ -569,6 +571,11 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
|
||||
}
|
||||
|
||||
wgPortString := "N/A"
|
||||
if o.WgPort > 0 {
|
||||
wgPortString = fmt.Sprintf("%d", o.WgPort)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf(
|
||||
"OS: %s\n"+
|
||||
"Daemon version: %s\n"+
|
||||
@@ -582,6 +589,7 @@ 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"+
|
||||
@@ -601,6 +609,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
interfaceIP,
|
||||
ipv6Line,
|
||||
interfaceTypeString,
|
||||
wgPortString,
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
|
||||
@@ -94,6 +94,7 @@ 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",
|
||||
@@ -210,6 +211,7 @@ var overview = OutputOverview{
|
||||
IPv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
FQDN: "some-localhost.awesome-domain.com",
|
||||
NSServerGroups: []NsServerGroupStateOutput{
|
||||
{
|
||||
@@ -369,6 +371,7 @@ 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,
|
||||
@@ -487,6 +490,7 @@ 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
|
||||
@@ -579,12 +583,13 @@ 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)
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
|
||||
|
||||
assert.Equal(t, expectedDetail, detail)
|
||||
}
|
||||
@@ -604,6 +609,7 @@ 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
|
||||
|
||||
@@ -9,7 +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_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||
- `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_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.
|
||||
@@ -36,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` / `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). |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `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}`; `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. |
|
||||
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
|
||||
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
|
||||
|
||||
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
|
||||
@@ -95,6 +95,7 @@ The main window is created up front in `main.go`. Auxiliary windows are created
|
||||
- **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.
|
||||
- **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()`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -147,6 +148,8 @@ 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.
|
||||
|
||||
@@ -5,4 +5,5 @@ Icon=netbird
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
Keywords=netbird;
|
||||
Keywords=netbird;
|
||||
StartupWMClass=org.wails.netbird
|
||||
@@ -41,6 +41,11 @@ 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
|
||||
@@ -61,9 +66,11 @@ tasks:
|
||||
- cmd: rm -f *.syso
|
||||
platforms: [linux, darwin]
|
||||
vars:
|
||||
# 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"'
|
||||
# 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}}'
|
||||
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
|
||||
env:
|
||||
|
||||
@@ -27,6 +27,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
| `/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=`) |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
@@ -54,6 +55,7 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
|
||||
- `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`.
|
||||
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
|
||||
|
||||
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
|
||||
- `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`."
|
||||
@@ -67,7 +69,7 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- 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`.
|
||||
- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. Management-URL utilities (`CLOUD_MANAGEMENT_URL`, URL regex, `isValidManagementUrl`, `normalizeManagementUrl`, `isCloudManagementUrl`, `checkManagementUrlReachable`) live alongside the hook in `hooks/useManagementUrl.ts`. The SSO orchestrator (`startLogin` + `EVENT_TRIGGER_LOGIN` / `EVENT_BROWSER_LOGIN_CANCEL`) lives at module scope inside `modules/main/MainConnectionStatusSwitch.tsx` — the only caller.
|
||||
- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there.
|
||||
|
||||
## Wails event bus
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
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 { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
@@ -39,6 +40,7 @@ Promise.all([
|
||||
<Route path="install-progress" element={<UpdateInProgressDialog />} />
|
||||
<Route path="session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
<Route path="welcome" element={<WelcomeDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 232 B |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 505 B |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 273 B |
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-darwin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
BIN
client/ui/frontend/src/assets/img/tray-windows.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -4,7 +4,7 @@ import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CheckIcon, ChevronDown, Search } from "lucide-react";
|
||||
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
@@ -13,43 +13,17 @@ import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
// (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",
|
||||
});
|
||||
// Intentionally no flag icons here: flags represent countries, not
|
||||
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
|
||||
// etc.). Each label shows the endonym followed by the englishName in
|
||||
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
|
||||
// the trigger and the dropdown rows.
|
||||
// See: https://www.flagsarenotlanguages.com/blog/
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const labelFor = (lang: Language): string =>
|
||||
lang.englishName && lang.englishName !== lang.displayName
|
||||
? `${lang.displayName} (${lang.englishName})`
|
||||
: lang.displayName;
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -121,9 +95,9 @@ export function LanguagePicker() {
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{current && <Flag code={current.code} label={current.displayName} />}
|
||||
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current?.displayName ?? "—"}
|
||||
{current ? labelFor(current) : "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
@@ -193,12 +167,8 @@ export function LanguagePicker() {
|
||||
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
|
||||
)}
|
||||
>
|
||||
<Flag
|
||||
code={lang.code}
|
||||
label={lang.displayName}
|
||||
/>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{lang.displayName}
|
||||
<span className={"flex-1 min-w-0 truncate"}>
|
||||
{labelFor(lang)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -7,21 +7,28 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
onChange: (mode: ManagementMode) => void;
|
||||
// fullWidth stretches the segmented control to fill its container —
|
||||
// the SettingsGeneral row uses the default (shrink-to-content) layout,
|
||||
// the welcome dialog asks for the wide variant so the picker spans the
|
||||
// narrow dialog width.
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: 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}>
|
||||
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>
|
||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||
{t("settings.general.management.selfHosted")}
|
||||
</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -9,8 +9,15 @@ type Props = {
|
||||
align?: RTooltip.TooltipContentProps["align"];
|
||||
delayDuration?: number;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
interactive?: boolean;
|
||||
keepOpenOnClick?: boolean;
|
||||
// Overrides the default tooltip-content chrome (background, padding,
|
||||
// border, radius). Use when a richer body needs popover-style layout.
|
||||
contentClassName?: string;
|
||||
// Ms to wait after pointer-leave before closing. Lets the user cross
|
||||
// a gap between trigger and content without the tooltip snapping shut.
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -20,14 +27,35 @@ 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);
|
||||
};
|
||||
|
||||
@@ -41,10 +69,11 @@ export const Tooltip = ({
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
hoveringRef.current = true;
|
||||
cancelClose();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoveringRef.current = false;
|
||||
setOpen(false);
|
||||
scheduleClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -54,15 +83,19 @@ export const Tooltip = ({
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
onPointerEnter={interactive ? cancelClose : undefined}
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={
|
||||
interactive ? undefined : (e) => e.preventDefault()
|
||||
}
|
||||
className={cn(
|
||||
"z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
"text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none 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}
|
||||
|
||||
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
42
client/ui/frontend/src/components/TruncatedText.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
tooltipContent?: ReactNode;
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
|
||||
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
|
||||
// the "tooltip on hover even though everything fits" annoyance. The caller
|
||||
// supplies the wrapper styling (font, max-width, etc.) via className — this
|
||||
// component only owns the truncate + measure + tooltip behavior.
|
||||
export const TruncatedText = ({
|
||||
text,
|
||||
className,
|
||||
tooltipContent,
|
||||
delayDuration = 600,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [text]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
|
||||
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm select-none cursor-default",
|
||||
"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",
|
||||
],
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-5 text-center px-8 py-6",
|
||||
"flex flex-col items-center gap-5 text-center px-8 pt-6 pb-7",
|
||||
isMacOS() && "pt-10",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,11 +3,32 @@ import { cn } from "@/lib/cn";
|
||||
|
||||
// DialogDescription is the supporting description text rendered under a
|
||||
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogDescriptionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
|
||||
<p className={cn("text-sm text-nb-gray-300 select-none", className)}>{children}</p>
|
||||
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
|
||||
// w-full for the same reason DialogHeading carries it — see the
|
||||
// comment there. The default text-center remains visually identical
|
||||
// to before; left/right alignment now anchors to the dialog content
|
||||
// edge instead of collapsing to no-op on a content-width box.
|
||||
<p
|
||||
className={cn(
|
||||
"w-full text-sm text-nb-gray-300 select-none",
|
||||
alignClass[align],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,34 @@ import { cn } from "@/lib/cn";
|
||||
// DialogHeading is the title text used inside ConfirmDialog (and any other
|
||||
// dialog-style surface with the same shape). Pair with DialogDescription
|
||||
// for the standard title/description stack.
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
type DialogHeadingProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
};
|
||||
|
||||
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
|
||||
<p className={cn("text-base font-semibold text-nb-gray-50 select-none", className)}>
|
||||
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
|
||||
// w-full so the alignClass actually has a box to anchor against.
|
||||
// The wrapping <p> defaulted to content width inside a flex column,
|
||||
// which made `text-left` a no-op (nothing to push the text away
|
||||
// from). Stretching the element is invisible for the default
|
||||
// text-center case (center of content == center of box) and lets
|
||||
// text-left/right line up with the dialog's content edge.
|
||||
<p
|
||||
className={cn(
|
||||
"w-full text-base font-semibold text-nb-gray-50 select-none",
|
||||
alignClass[align],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -31,11 +31,11 @@ export const EmptyState = ({
|
||||
<div className={cn("py-12 text-center", className)}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center max-w-sm mx-auto relative top-7"
|
||||
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-6"
|
||||
}
|
||||
>
|
||||
<SquareIcon icon={icon} className={"mb-3"} />
|
||||
<p className={"text-base font-medium text-nb-gray-200 mb-1"}>{title}</p>
|
||||
<p className={"text-[0.95rem] 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"}>
|
||||
|
||||
@@ -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.5"}
|
||||
className={"relative top-[2.8rem]"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export const NotConnectedState = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-full min-h-[260px] flex-1 flex items-center justify-center px-6 pb-20 top-1 relative"
|
||||
"h-full flex-1 flex items-start justify-center pt-36 top-[0.6rem] px-6 relative"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
@@ -3,21 +3,12 @@ import { warningDialog } from "@/lib/dialogs.ts";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
|
||||
function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const URL_PATTERN = new RegExp(
|
||||
// URL_PATTERN matches http(s)://host[:port][/path][?query][#fragment].
|
||||
// Host is domain, localhost, or IPv4. Used for syntactic validation only —
|
||||
// reachability is checked separately via checkManagementUrlReachable.
|
||||
export const URL_PATTERN = new RegExp(
|
||||
"^(https?:\\/\\/)?" +
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||
@@ -27,12 +18,60 @@ const URL_PATTERN = new RegExp(
|
||||
"i",
|
||||
);
|
||||
|
||||
function isValidManagementUrl(input: string): boolean {
|
||||
// normalizeManagementUrl prefixes an https:// scheme when the user omits
|
||||
// it. Empty input stays empty.
|
||||
export function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
// isValidManagementUrl is a syntactic check via URL_PATTERN. Does not
|
||||
// touch the network.
|
||||
export function isValidManagementUrl(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
return URL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
// isCloudManagementUrl reports whether the stored URL is the NetBird
|
||||
// Cloud default (or an empty/unset URL, which the daemon also treats as
|
||||
// cloud-defaulting on first boot).
|
||||
export function isCloudManagementUrl(url: string): boolean {
|
||||
if (!url || url.trim() === "") return true;
|
||||
return url === CLOUD_MANAGEMENT_URL;
|
||||
}
|
||||
|
||||
// checkManagementUrlReachable does a best-effort no-cors GET against the
|
||||
// URL with a short timeout. A resolved fetch (even opaque) means DNS +
|
||||
// TCP + TLS landed; any rejection (network error, DNS, abort) is treated
|
||||
// as unreachable. Self-hosted deployments behind internal-only DNS or
|
||||
// with self-signed certs may return false positives — callers should
|
||||
// surface this as a soft warning, not a hard block.
|
||||
export async function checkManagementUrlReachable(
|
||||
url: string,
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<boolean> {
|
||||
const target = normalizeManagementUrl(url);
|
||||
if (!target) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
function modeFromUrl(url: string): ManagementMode {
|
||||
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
children: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
overlayOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// iOS-style push transition: incoming pane slides in from the right while
|
||||
@@ -16,13 +17,14 @@ const PANEL_TRANSITION = {
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-4",
|
||||
"wails-no-draggable relative m-5",
|
||||
"bg-nb-gray-940 border border-nb-gray-920",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
@@ -29,17 +29,21 @@ for (const path in bundleModules) {
|
||||
}
|
||||
|
||||
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||
// and returns the first base code ("de" from "de-DE") that has a shipped
|
||||
// bundle. Returns null when none match, so the caller can fall back to
|
||||
// English. We only ever match against the lowercased base — region tags
|
||||
// don't have separate bundles today.
|
||||
// and returns the first shipped bundle that matches. We try an exact
|
||||
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
|
||||
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
|
||||
// null when nothing matches, so the caller can fall back to English.
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
|
||||
for (const tag of tags) {
|
||||
const base = tag.toLowerCase().split("-")[0];
|
||||
if (available.includes(base)) return base;
|
||||
const lower = tag.toLowerCase();
|
||||
const exact = byLower.get(lower);
|
||||
if (exact) return exact;
|
||||
const base = byLower.get(lower.split("-")[0]);
|
||||
if (base) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,43 +12,65 @@ import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
|
||||
// connState collapses them into Connecting or Disconnected upstream.
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
|
||||
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
|
||||
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
|
||||
// device code.
|
||||
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
|
||||
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
|
||||
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
|
||||
// The tray emits it from menu items so the React UI (which owns the SSO
|
||||
// orchestration and the browser-login window) takes over.
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// Re-enable the switch after this long in a transitioning state so the user
|
||||
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
|
||||
// popup window is the only login UI; errors surface as a native
|
||||
// Dialogs.Error. Concurrent calls are dropped via the inFlight guard.
|
||||
// loginInFlight is a module-level guard. SSO login involves multiple async
|
||||
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
|
||||
// call would race on the daemon's pending device code and on the popup
|
||||
// window's singleton, leading to confusing UX. Calls past the first are
|
||||
// dropped silently — the first invocation owns the flow until it settles.
|
||||
let loginInFlight = false;
|
||||
async function startLogin(): Promise<void> {
|
||||
if (loginInFlight) return;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end:
|
||||
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
|
||||
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
|
||||
// 3. Race WaitSSOLogin vs the user clicking Cancel.
|
||||
// 4. On success: Connection.Up.
|
||||
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
|
||||
// drops the abandoned device code (avoids an Idle blink on the tray).
|
||||
//
|
||||
// Errors that aren't user cancellations surface via errorDialog. Concurrent
|
||||
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
|
||||
// all exit paths so a stray popup doesn't outlive the flow.
|
||||
// startLogin drives the SSO flow. onSettled is invoked exactly once, the
|
||||
// instant the flow itself is over (success, cancel, or error) — BEFORE the
|
||||
// error dialog is shown. Every guard that gates re-arming the login path
|
||||
// (the module-level loginInFlight here, and the caller's React-level
|
||||
// loginGuard via onSettled) must be released at that point, never gated on
|
||||
// the dialog.
|
||||
//
|
||||
// Why the dialog must be outside the guards: the native Windows MessageBox
|
||||
// disables its parent for its whole lifetime, and the main window's
|
||||
// WindowClosing hook hides instead of closing — the two race and the dialog
|
||||
// promise can hang indefinitely (see WAILS-DIALOGS notes). If any guard's
|
||||
// release awaited the dialog, that guard would stay held for as long as the
|
||||
// box is open (or forever if it hangs), and every later Connect / tray
|
||||
// trigger-login would be silently dropped at the guard check until the
|
||||
// client is restarted. That was the original "can't log in again until
|
||||
// restart" bug.
|
||||
async function startLogin(onSettled?: () => void): Promise<void> {
|
||||
if (loginInFlight) {
|
||||
// The caller's guard must still be released — it was set before this
|
||||
// call. Without this the React-level loginGuard would wedge on a
|
||||
// dropped concurrent invocation.
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
loginInFlight = true;
|
||||
|
||||
let cancelled = false;
|
||||
let offCancel: (() => void) | undefined;
|
||||
let loginError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
@@ -64,10 +86,6 @@ async function startLogin(): Promise<void> {
|
||||
if (result.needsSsoLogin) {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) {
|
||||
// Open the in-app sign-in popup first; the dialog itself
|
||||
// fires Connection.OpenURL after it's actually on screen
|
||||
// (see WaitingForBrowserDialog) so the system browser
|
||||
// doesn't land on top of a still-hidden NetBird window.
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
@@ -94,12 +112,6 @@ async function startLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
// Cancel the in-flight WaitSSOLogin gRPC instead of a heavy
|
||||
// Down. The daemon ties the wait to this call's context
|
||||
// (server.go WaitSSOLogin), so cancelling ends the wait and
|
||||
// clears the abandoned OAuth flow — a fresh Login then starts
|
||||
// a new device code, with no Idle blink on the tray. Swallow
|
||||
// the cancellation rejection on the abandoned promise.
|
||||
waitPromise.cancel?.();
|
||||
void waitPromise.catch(() => {});
|
||||
return;
|
||||
@@ -109,17 +121,48 @@ async function startLogin(): Promise<void> {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (cancelled) return;
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
if (!cancelled) loginError = e;
|
||||
} finally {
|
||||
offCancel?.();
|
||||
// Release every guard before any UI work below — never gate re-arming
|
||||
// the login path on a dialog that can hang. loginInFlight is ours;
|
||||
// onSettled releases the caller's React-level loginGuard.
|
||||
loginInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (loginError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(loginError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnecting = "disconnecting",
|
||||
}
|
||||
|
||||
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
|
||||
// connState collapses them into Connecting or Disconnected upstream.
|
||||
const STATUS_KEY: Record<ConnectionState, string> = {
|
||||
[ConnectionState.Disconnected]: "connect.status.disconnected",
|
||||
[ConnectionState.Connecting]: "connect.status.connecting",
|
||||
[ConnectionState.Connected]: "connect.status.connected",
|
||||
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
|
||||
};
|
||||
|
||||
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
|
||||
|
||||
// Re-enable the switch after this long in a transitioning state so the user
|
||||
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
@@ -151,7 +194,12 @@ export const MainConnectionStatusSwitch = () => {
|
||||
if (loginGuard.current) return;
|
||||
loginGuard.current = true;
|
||||
setAction("logging-in");
|
||||
void startLogin().finally(() => {
|
||||
// Release the React-level guard via onSettled — fired the instant the
|
||||
// flow ends, before startLogin's error dialog. Gating it on the full
|
||||
// startLogin() promise would keep loginGuard wedged for the whole
|
||||
// dialog lifetime, leaving the tray's trigger-login dropped at the
|
||||
// guard check until the client is restarted.
|
||||
void startLogin(() => {
|
||||
loginGuard.current = false;
|
||||
setAction(null);
|
||||
void refresh();
|
||||
@@ -343,7 +391,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const ip = status?.local.ip || "";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-5",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
@@ -375,7 +427,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
showLocal && fqdn ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
|
||||
{fqdn || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
@@ -387,7 +439,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-xs leading-tight text-nb-gray-300"}>
|
||||
<span className={"font-mono text-[0.8rem] leading-tight text-nb-gray-300"}>
|
||||
{ip || " "}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
|
||||
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { forwardRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronsUpDown, LucideProps, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const MainExitNodeSwitcher = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const active = exitNodes.find((n) => n.selected) ?? null;
|
||||
const isConnected = status?.status === "Connected";
|
||||
const hasAny = exitNodes.length > 0;
|
||||
const disabled = !isConnected || !hasAny;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) void toggleExitNode(active.id, true);
|
||||
return;
|
||||
}
|
||||
if (active && active.id === next) return;
|
||||
void toggleExitNode(next, false);
|
||||
};
|
||||
|
||||
const title = active ? active.id : t("exitNodes.card.title");
|
||||
const description = !hasAny
|
||||
? t("exitNodes.empty.title")
|
||||
: active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ExitNodeTriggerCard
|
||||
title={title}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
active={!!active}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
side={"top"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Command.List>
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
|
||||
<ScrollArea.Viewport className={"max-h-72 px-1"}>
|
||||
{exitNodes.map((n) => (
|
||||
<ExitNodeRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
label={n.id}
|
||||
isActive={active?.id === n.id}
|
||||
onSelect={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
title: string;
|
||||
description: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function ExitNodeTriggerCard(
|
||||
{ title, description, disabled, active = false, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
active
|
||||
? "bg-green-500/25 text-green-400"
|
||||
: "bg-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<ExitNodeIcon size={14} />
|
||||
</div>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
|
||||
<TruncatedText
|
||||
text={description}
|
||||
className={
|
||||
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type NoneRowProps = {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Command.Item
|
||||
value={NONE_VALUE}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodeRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
<Command.Item
|
||||
value={id}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
import {isWindows} from "@/lib/platform.ts";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
@@ -143,21 +143,24 @@ export const MainHeader = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable relative",
|
||||
"flex items-center h-12 top-2.5",
|
||||
"shrink-0 cursor-default wails-draggable relative z-10",
|
||||
"flex items-center h-12 top-3",
|
||||
)}
|
||||
>
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-3 items-center shrink-0",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
|
||||
{settingsSlot}
|
||||
</div>
|
||||
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
|
||||
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
|
||||
import { MainHeader } from "@/modules/main/MainHeader.tsx";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
|
||||
@@ -9,7 +10,6 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
@@ -37,8 +37,11 @@ const MainBody = () => {
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<MainConnectionStatusSwitch />
|
||||
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
|
||||
<MainExitNodeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<NavSectionProvider>
|
||||
@@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => {
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
@@ -68,7 +75,6 @@ const AdvancedAppRightPanel = () => {
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -28,11 +28,6 @@ export const Navigation = () => {
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
},
|
||||
{
|
||||
value: "exitNode",
|
||||
label: t("nav.exitNode.title"),
|
||||
icon: ExitNodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -72,12 +67,4 @@ export const Navigation = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
export type { NavSection } from "@/contexts/NavSectionContext";
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
|
||||
// the optimistic radio flip paints in place instead of the row jumping to
|
||||
// the top.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const byId = new Map(exitNodes.map((n) => [n.id, n]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = exitNodes
|
||||
.filter((n) => !known.has(n.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((n) => n.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [exitNodes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return ordered;
|
||||
return ordered.filter((r) => r.id.toLowerCase().includes(q));
|
||||
}, [ordered, search]);
|
||||
|
||||
if (isConnected && exitNodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={WaypointsIcon}
|
||||
title={t("exitNodes.empty.title")}
|
||||
description={t("exitNodes.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
|
||||
learnMoreTopic={t("nav.exitNode.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("exitNodes.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodesListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
className={"flex flex-col"}
|
||||
>
|
||||
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
|
||||
{data.map((n) => (
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
const Row = ({ value, label, first }: RowProps) => (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
|
||||
first && "mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
"border-nb-gray-700 bg-nb-gray-900",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={"h-2 w-2 rounded-full bg-white"}
|
||||
/>
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
@@ -39,23 +39,17 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
@@ -76,11 +77,7 @@ export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const {
|
||||
networkRoutes: realNetworkRoutes,
|
||||
toggleNetwork,
|
||||
setNetworksSelected,
|
||||
} = useNetworks();
|
||||
const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
@@ -164,9 +161,7 @@ export const Networks = () => {
|
||||
|
||||
const selectedInView = filtered.filter((r) => r.selected).length;
|
||||
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
|
||||
const bulkLabel = allSelected
|
||||
? t("networks.bulk.disableAll")
|
||||
: t("networks.bulk.enableAll");
|
||||
const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
@@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
key={n.id}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
@@ -271,29 +266,21 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<span
|
||||
<TruncatedText
|
||||
text={n.id}
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{n.id}
|
||||
</span>
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
<div
|
||||
className={"shrink-0 self-center"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"shrink-0 self-center"} onClick={(e) => e.stopPropagation()}>
|
||||
<NetworkToggle
|
||||
checked={n.selected}
|
||||
onChange={() => onToggle(n.id, n.selected)}
|
||||
label={
|
||||
n.selected
|
||||
? t("networks.selected")
|
||||
: t("networks.unselected")
|
||||
}
|
||||
label={n.selected ? t("networks.selected") : t("networks.unselected")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
return (
|
||||
<div>
|
||||
<CopyToClipboard message={network.range}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{network.range}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
className={
|
||||
"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
@@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
|
||||
const first = ips[0];
|
||||
const extra = ips.length - 1;
|
||||
|
||||
const span = (
|
||||
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{domain}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{first && (
|
||||
<div className={"flex items-center gap-1.5 min-w-0"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<span className={"text-xs font-mono text-nb-gray-500 truncate"}>
|
||||
{first}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div>
|
||||
<CopyToClipboard message={domain}>
|
||||
{ips.length > 0 ? (
|
||||
<Tooltip
|
||||
content={<ResolvedIpsTooltip ips={ips} />}
|
||||
delayDuration={300}
|
||||
closeDelay={300}
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
alignOffset={-8}
|
||||
interactive
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
{span}
|
||||
</Tooltip>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
|
||||
const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const extra = ips.length - 1;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
|
||||
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-pointer outline-none",
|
||||
)}
|
||||
>
|
||||
{t("networks.ips.more", { count: extra })}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side={"bottom"}
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
className={"px-1 py-0.5"}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<>
|
||||
<div className={"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-300"}>
|
||||
{t("networks.ips.heading")}
|
||||
</div>
|
||||
<ul className={"flex flex-col"}>
|
||||
{ips.map((ip) => (
|
||||
<li key={ip}>
|
||||
<CopyToClipboard message={ip} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-100 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
mixed
|
||||
? "translate-x-2.5"
|
||||
: checked
|
||||
? "translate-x-[1.125rem]"
|
||||
: "translate-x-0.5",
|
||||
mixed ? "translate-x-2.5" : checked ? "translate-x-[1.125rem]" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
@@ -16,7 +8,8 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
CableIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClockIcon,
|
||||
GaugeIcon,
|
||||
HandshakeIcon,
|
||||
@@ -25,15 +18,15 @@ import {
|
||||
LucideProps,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
ZapIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
@@ -222,7 +215,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never"));
|
||||
const statusSince = formatAge(peer.connStatusUpdateUnix, DASH);
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
|
||||
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
|
||||
|
||||
return (
|
||||
@@ -242,11 +234,24 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
)}
|
||||
</Row>
|
||||
{isConnected && (
|
||||
<Row icon={CableIcon} label={t("peers.details.connection")}>
|
||||
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
|
||||
<ConnectionIcon size={13} />
|
||||
{connectionLabel}
|
||||
</span>
|
||||
<Row icon={ChevronsLeftRightEllipsisIcon} label={t("peers.details.connection")}>
|
||||
<span className={"whitespace-nowrap"}>{connectionLabel}</span>
|
||||
</Row>
|
||||
)}
|
||||
{peer.relayed && (
|
||||
<Row icon={WaypointsIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.latencyMs > 0 && (
|
||||
@@ -282,6 +287,11 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
|
||||
{statusSince}
|
||||
</Row>
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<IceRow
|
||||
icon={MonitorIcon}
|
||||
baseLabel={t("peers.details.localIce")}
|
||||
@@ -294,27 +304,6 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
type={peer.remoteIceCandidateType}
|
||||
endpoint={peer.remoteIceCandidateEndpoint}
|
||||
/>
|
||||
{peer.relayed && (
|
||||
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
|
||||
{peer.relayAddress ? (
|
||||
<CopyToClipboard
|
||||
message={peer.relayAddress}
|
||||
alwaysShowIcon
|
||||
className={"max-w-full min-w-0"}
|
||||
iconClassName={"top-0"}
|
||||
>
|
||||
<TruncatedRowValue value={peer.relayAddress} mono />
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
DASH
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{peer.networks.length > 0 && (
|
||||
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||
<ResourcesValue networks={peer.networks} />
|
||||
</Row>
|
||||
)}
|
||||
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
|
||||
{peer.pubKey ? (
|
||||
<CopyToClipboard
|
||||
@@ -370,67 +359,35 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// "{N} Resources" trigger that opens a hover popover listing each routed
|
||||
// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs
|
||||
// popover in the Resources tab (Networks.tsx ResolvedIpsPopover).
|
||||
// Row value: first network inline, plus a "+N more" hover pill opening a
|
||||
// popover with the full list. Mirrors the resolved-IPs pattern in
|
||||
// Networks.tsx so the Resources tab and the peer detail row look consistent.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => {
|
||||
const first = networks[0];
|
||||
const extra = networks.length - 1;
|
||||
return (
|
||||
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
|
||||
<CopyToClipboard message={first}>
|
||||
<TruncatedRowValue value={first} mono />
|
||||
</CopyToClipboard>
|
||||
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Single "View {n}" badge with a chevron that opens a click popover listing
|
||||
// each routed resource on its own line with a click-to-copy entry. Avoids
|
||||
// the repetitive "first item + N more" pattern given the row already has a
|
||||
// "Resources" label and Layers icon.
|
||||
const ResourcesValue = ({ networks }: { networks: string[] }) => (
|
||||
<ResourcesPopover networks={networks} />
|
||||
);
|
||||
|
||||
const ResourcesMorePopover = ({
|
||||
networks,
|
||||
extra,
|
||||
}: {
|
||||
networks: string[];
|
||||
extra: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
// Close with a small delay so the mouse can cross the sideOffset gap
|
||||
// between trigger and content without the popover snapping shut.
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 300);
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
className={cn(
|
||||
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
|
||||
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none",
|
||||
"shrink-0 inline-flex items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
"border border-nb-gray-900",
|
||||
"px-2 py-1 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
)}
|
||||
>
|
||||
{t("peers.details.more", { count: extra })}
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -438,13 +395,11 @@ const ResourcesMorePopover = ({
|
||||
side={"bottom"}
|
||||
align={"end"}
|
||||
sideOffset={6}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 w-64 max-h-72 overflow-auto",
|
||||
"z-50 max-w-[18rem] max-h-72 overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-2 shadow-lg outline-none",
|
||||
"p-2 pr-4 shadow-lg outline-none",
|
||||
)}
|
||||
>
|
||||
<ul className={"flex flex-col"}>
|
||||
@@ -453,7 +408,7 @@ const ResourcesMorePopover = ({
|
||||
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
|
||||
<span
|
||||
className={
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||
"font-mono text-[0.72rem] text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
@@ -468,37 +423,15 @@ const ResourcesMorePopover = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Truncates the value to one line with the row's available width; on hover,
|
||||
// shows the full string in a tooltip — but only when actually clipped. Same
|
||||
// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown.
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [value]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={value} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
|
||||
<TruncatedText
|
||||
text={value}
|
||||
className={cn(
|
||||
"inline-block truncate align-middle min-w-0 max-w-[260px]",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-100",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"wails-no-draggable cursor-default",
|
||||
@@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[active.value]})
|
||||
</span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={"text-nb-gray-400 ml-0.5 shrink-0"}
|
||||
/>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>
|
||||
({counts[f.value]})
|
||||
</span>
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
/>
|
||||
)}
|
||||
<span className={"w-4 shrink-0 flex items-center justify-center"}>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { mockOr, mockPeers } from "@/lib/mock";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
|
||||
@@ -67,27 +68,55 @@ export const Peers = () => {
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. After
|
||||
// that, positions are sticky — a peer flipping Connected→Connecting→Idle
|
||||
// no longer jumps groups. Newly discovered peers append at the end
|
||||
// (sorted online-first / by-name among themselves). Mirrors the
|
||||
// networks-list and exit-nodes-list orderRef pattern.
|
||||
// Initial order: online-first, then alphabetically by fqdn / ip. Once
|
||||
// peers have settled, positions become sticky — a peer flipping
|
||||
// Connected→Connecting→Idle no longer jumps groups. Newly discovered
|
||||
// peers append at the end (sorted online-first / by-name among
|
||||
// themselves). Mirrors the networks-list and exit-nodes-list orderRef
|
||||
// pattern.
|
||||
//
|
||||
// Stay in live-sort mode until every peer has reached a stable state
|
||||
// (Connected or Idle). The daemon emits all peers as "Connecting" right
|
||||
// after Up, which collapses the online-first sort into pure
|
||||
// alphabetical — committing then would lock that incorrect order and
|
||||
// the list would stay alphabetical even after every peer becomes
|
||||
// Connected. Once nothing is Connecting we commit and go sticky.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const stickyRef = useRef(false);
|
||||
const ordered = useMemo(() => {
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = peers
|
||||
.filter((p) => !known.has(p.pubKey))
|
||||
.sort((a, b) => {
|
||||
const sortOnlineFirst = (list: PeerStatus[]) =>
|
||||
[...list].sort((a, b) => {
|
||||
const aOnline = isOnline(a.connStatus);
|
||||
const bOnline = isOnline(b.connStatus);
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
||||
const aName = (a.fqdn || a.ip).toLowerCase();
|
||||
const bName = (b.fqdn || b.ip).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
})
|
||||
.map((p) => p.pubKey);
|
||||
});
|
||||
|
||||
// Reset on empty (Disconnect → reconnect) so the next session
|
||||
// re-sorts from scratch instead of replaying the stale orderRef.
|
||||
if (peers.length === 0) {
|
||||
orderRef.current = [];
|
||||
stickyRef.current = false;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!stickyRef.current) {
|
||||
const sorted = sortOnlineFirst(peers);
|
||||
if (peers.every((p) => p.connStatus !== "Connecting")) {
|
||||
orderRef.current = sorted.map((p) => p.pubKey);
|
||||
stickyRef.current = true;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const byKey = new Map(peers.map((p) => [p.pubKey, p]));
|
||||
const kept = orderRef.current.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map(
|
||||
(p) => p.pubKey,
|
||||
);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((k) => byKey.get(k)!);
|
||||
@@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
|
||||
"group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={t(peerStatusLabelKey(peer.connStatus))}
|
||||
side={"left"}
|
||||
>
|
||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
@@ -190,7 +216,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<TruncatedName name={peer.fqdn} />
|
||||
<TruncatedText
|
||||
text={peer.fqdn}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
@@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a
|
||||
// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout,
|
||||
// and wrap in a Tooltip only when the text actually overflows. Avoids the
|
||||
// "tooltip on hover even though everything fits" annoyance.
|
||||
const TruncatedName = ({ name }: { name: string }) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [name]);
|
||||
|
||||
const span = (
|
||||
<span
|
||||
ref={ref}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return (
|
||||
<Tooltip content={name} delayDuration={600}>
|
||||
{span}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,19 @@ type Props = {
|
||||
onCreate: (name: string) => void;
|
||||
};
|
||||
|
||||
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
|
||||
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
|
||||
// `_` and `-` survive on the Go side. We additionally lowercase and convert
|
||||
// spaces to `-` so what the user sees in the input is exactly what the
|
||||
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
|
||||
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
|
||||
// ghost row and breaks subsequent delete.
|
||||
const sanitizeProfileInput = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
@@ -26,18 +39,18 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
const sanitized = sanitizeProfileInput(name);
|
||||
if (sanitized.length === 0) {
|
||||
setError(t("profile.dialog.required"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onCreate(trimmed);
|
||||
onCreate(sanitized);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setName(value);
|
||||
setName(sanitizeProfileInput(value));
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
@@ -60,6 +73,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
@@ -31,6 +31,20 @@ export function ProfilesTab() {
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const tabRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// After a successful switch we want to bring the user back to the top of
|
||||
// the tab — the table re-sorts the new active profile to the row 0 and a
|
||||
// user who scrolled to find a target down the list would otherwise lose
|
||||
// visual anchoring. Settings is hosted inside a Radix ScrollArea so we
|
||||
// walk up to the viewport (it owns the actual overflow) instead of
|
||||
// `window.scrollTo`, which is a no-op here.
|
||||
const scrollTabToTop = () => {
|
||||
const el = tabRootRef.current?.closest<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
el?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
@@ -53,6 +67,22 @@ export function ProfilesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = async (name: string) => {
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("profile.switch.confirm");
|
||||
const result = await warningDialog({
|
||||
Title: i18next.t("profile.switch.title"),
|
||||
Message: i18next.t("profile.switch.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== confirmLabel) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
scrollTabToTop();
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("profile.deregister.confirm");
|
||||
@@ -97,7 +127,7 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={tabRootRef}>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
@@ -113,6 +143,7 @@ export function ProfilesTab() {
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSwitch={() => handleSwitch(profile.name)}
|
||||
onDeregister={() => handleDeregister(profile.name)}
|
||||
onDelete={() => handleDelete(profile.name)}
|
||||
/>
|
||||
@@ -146,18 +177,19 @@ export function ProfilesTab() {
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onSwitch: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
const showEmail = !!profile.email;
|
||||
@@ -192,8 +224,11 @@ const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowPro
|
||||
</td>
|
||||
<td className={"px-4 py-2.5 text-right align-middle"}>
|
||||
<RowActions
|
||||
canSwitch={!isActive}
|
||||
canDeregister={!!profile.email}
|
||||
canDelete={profile.name !== DEFAULT_PROFILE}
|
||||
isDefault={profile.name === DEFAULT_PROFILE}
|
||||
isActive={isActive}
|
||||
onSwitch={onSwitch}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -222,14 +257,31 @@ const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
canSwitch: boolean;
|
||||
canDeregister: boolean;
|
||||
canDelete: boolean;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
onSwitch: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
|
||||
const RowActions = ({
|
||||
canSwitch,
|
||||
canDeregister,
|
||||
isDefault,
|
||||
isActive,
|
||||
onSwitch,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
const deleteLabel = isDefault
|
||||
? t("profile.delete.disabledDefault")
|
||||
: isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
@@ -239,11 +291,17 @@ const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowAct
|
||||
hidden={!canDeregister}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.delete")}
|
||||
label={deleteLabel}
|
||||
icon={Trash2}
|
||||
onClick={onDelete}
|
||||
variant={"danger"}
|
||||
hidden={!canDelete}
|
||||
disabled={deleteDisabled}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.switchTo")}
|
||||
icon={LogIn}
|
||||
onClick={onSwitch}
|
||||
hidden={!canSwitch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -257,6 +315,8 @@ type ActionIconButtonProps = {
|
||||
/** When true the button still occupies space (preserves row layout)
|
||||
* but is invisible and non-interactive. */
|
||||
hidden?: boolean;
|
||||
/** When true the button is visible but non-interactive (greyed out). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ActionIconButton = ({
|
||||
@@ -265,13 +325,15 @@ const ActionIconButton = ({
|
||||
onClick,
|
||||
variant = "default",
|
||||
hidden = false,
|
||||
disabled = false,
|
||||
}: ActionIconButtonProps) => {
|
||||
const button = (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onClick}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-label={label}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
className={cn(
|
||||
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
|
||||
@@ -280,6 +342,7 @@ const ActionIconButton = ({
|
||||
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
|
||||
hidden && "opacity-0 pointer-events-none",
|
||||
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
@@ -287,7 +350,12 @@ const ActionIconButton = ({
|
||||
);
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip content={label} side={"top"}>
|
||||
<Tooltip
|
||||
content={
|
||||
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
|
||||
}
|
||||
side={"top"}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SettingsAbout() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
|
||||
@@ -18,7 +18,8 @@ const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/;
|
||||
const INTERFACE_NAME_ERROR_KEY = IS_MAC
|
||||
? "settings.advanced.interfaceName.errorMac"
|
||||
: "settings.advanced.interfaceName.error";
|
||||
const PORT_MIN = 1;
|
||||
// Port 0 means "let the daemon pick a random free port" (see the hint text).
|
||||
const PORT_MIN = 0;
|
||||
const PORT_MAX = 65535;
|
||||
// Mirrors client/iface/iface.go MinMTU / MaxMTU. 576 is the IPv4 "every host
|
||||
// must accept" datagram size from RFC 791 — safe floor when IPv6 is off; for
|
||||
@@ -93,20 +94,23 @@ export function SettingsAdvanced() {
|
||||
}
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
min={PORT_MIN}
|
||||
max={PORT_MAX}
|
||||
value={values.wireguardPort}
|
||||
error={errors.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label={t("settings.advanced.port.label")}
|
||||
type={"number"}
|
||||
min={PORT_MIN}
|
||||
max={PORT_MAX}
|
||||
value={values.wireguardPort}
|
||||
error={errors.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<HelpText>{t("settings.advanced.port.help")}</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
label={t("settings.advanced.mtu.label")}
|
||||
type={"number"}
|
||||
|
||||
194
client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx
Normal file
194
client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Preferences,
|
||||
Profiles as ProfilesSvc,
|
||||
Settings as SettingsSvc,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog } from "@/lib/dialogs";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
|
||||
import { WelcomeStepTray } from "./WelcomeStepTray";
|
||||
import { WelcomeStepManagement } from "./WelcomeStepManagement";
|
||||
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
// WelcomeStep is the orchestrator's state machine. The transitions:
|
||||
// tray → management (if eligible) → finish
|
||||
// tray → finish (otherwise)
|
||||
// Login itself is no longer part of onboarding — once the welcome window
|
||||
// closes the user lands in the main window and clicks Connect there.
|
||||
type WelcomeStep = "tray" | "management";
|
||||
|
||||
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
|
||||
// on a pristine setup — default profile, no email recorded (no successful
|
||||
// login yet), and the management URL is either unset or already the cloud
|
||||
// default. Any other state means the user (or a previous run) already
|
||||
// made a deliberate choice and we shouldn't second-guess it.
|
||||
function shouldShowManagementStep(
|
||||
activeProfile: string,
|
||||
email: string,
|
||||
managementUrl: string,
|
||||
): boolean {
|
||||
if (activeProfile !== "default") return false;
|
||||
if (email.trim() !== "") return false;
|
||||
return isCloudManagementUrl(managementUrl);
|
||||
}
|
||||
|
||||
// initial flow snapshot resolved at mount. Held in component state so the
|
||||
// step-2 management input can hydrate from initialUrl, and so the
|
||||
// "should we even show step 2" check is computed once (the user can't
|
||||
// change profile / URL from inside the welcome window).
|
||||
type InitialState = {
|
||||
profileName: string;
|
||||
username: string;
|
||||
managementUrl: string;
|
||||
needsManagementStep: boolean;
|
||||
};
|
||||
|
||||
export default function WelcomeDialog() {
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [step, setStep] = useState<WelcomeStep>("tray");
|
||||
const [initial, setInitial] = useState<InitialState | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
// Probe daemon state on mount: who's the active profile, do they
|
||||
// have an email recorded, and what management URL is configured?
|
||||
// Errors fall through to "skip the management step" so a daemon
|
||||
// hiccup never blocks onboarding entirely.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Resolve username + active profile first so GetConfig + List
|
||||
// can target the actual profile (passing empty strings would
|
||||
// work today since the daemon falls back to the default
|
||||
// profile, but being explicit shields us from future
|
||||
// changes to that fallback).
|
||||
const [username, active] = await Promise.all([
|
||||
ProfilesSvc.Username(),
|
||||
ProfilesSvc.GetActive(),
|
||||
]);
|
||||
const profileName = active.profileName || "default";
|
||||
const [config, list] = await Promise.all([
|
||||
SettingsSvc.GetConfig({ profileName, username }),
|
||||
ProfilesSvc.List(username),
|
||||
]);
|
||||
const profile = list.find((p) => p.name === profileName);
|
||||
const email = profile?.email ?? "";
|
||||
if (cancelled) return;
|
||||
setInitial({
|
||||
profileName,
|
||||
username,
|
||||
managementUrl: config.managementUrl,
|
||||
needsManagementStep: shouldShowManagementStep(
|
||||
profileName,
|
||||
email,
|
||||
config.managementUrl,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("welcome: initial probe failed", e);
|
||||
if (cancelled) return;
|
||||
// Conservative fallback: skip the management step rather
|
||||
// than block onboarding behind a daemon hiccup.
|
||||
setInitial({
|
||||
profileName: "default",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
needsManagementStep: false,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// finish persists the onboarding flag, opens the main window so the
|
||||
// user has somewhere to land, and closes the welcome window. Called
|
||||
// at the end of every successful flow (tray-only and tray→management
|
||||
// alike). The Connect button in the main window picks up from here.
|
||||
const finish = useCallback(async () => {
|
||||
if (closing) return;
|
||||
setClosing(true);
|
||||
try {
|
||||
await Preferences.SetOnboardingCompleted(true);
|
||||
} catch (e) {
|
||||
console.error("persist onboarding flag:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.OpenMain();
|
||||
} catch (e) {
|
||||
console.error("open main window:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.CloseWelcome();
|
||||
} catch (e) {
|
||||
console.error("close welcome window:", e);
|
||||
}
|
||||
}, [closing]);
|
||||
|
||||
const handleTrayContinue = useCallback(async () => {
|
||||
if (initial?.needsManagementStep) {
|
||||
setStep("management");
|
||||
} else {
|
||||
await finish();
|
||||
}
|
||||
}, [initial, finish]);
|
||||
|
||||
const handleManagementContinue = useCallback(
|
||||
async (url: string) => {
|
||||
if (!initial) return;
|
||||
try {
|
||||
// SetConfig is a partial update — pointer fields left
|
||||
// undefined are preserved (services/settings.go). We only
|
||||
// touch managementUrl; adminUrl stays empty here because
|
||||
// the daemon already has its own value loaded.
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({
|
||||
profileName: initial.profileName,
|
||||
username: initial.username,
|
||||
managementUrl: url,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
|
||||
await finish();
|
||||
},
|
||||
[initial, finish],
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!initial) {
|
||||
// Probe in flight — render an empty container so the dialog
|
||||
// window measures something tiny instead of flashing the
|
||||
// tray step before we know whether step 2 applies. The probe
|
||||
// completes within a single tick on a healthy daemon.
|
||||
return <div className={"h-32"} />;
|
||||
}
|
||||
switch (step) {
|
||||
case "tray":
|
||||
return <WelcomeStepTray onContinue={handleTrayContinue} />;
|
||||
case "management":
|
||||
return (
|
||||
<WelcomeStepManagement
|
||||
initialUrl={initial.managementUrl}
|
||||
onContinue={handleManagementContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [initial, step, handleTrayContinue, handleManagementContinue]);
|
||||
|
||||
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
|
||||
}
|
||||
138
client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx
Normal file
138
client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isCloudManagementUrl,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
type WelcomeStepManagementProps = {
|
||||
// initialUrl is the management URL the daemon is already configured
|
||||
// with (empty / cloud-default both render as Cloud selected).
|
||||
initialUrl: string;
|
||||
// onContinue is invoked with the URL the user wants to persist. The
|
||||
// parent owns the actual Settings.SetConfig call so the dialog stays
|
||||
// free of context dependencies.
|
||||
onContinue: (url: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
|
||||
const { t } = useTranslation();
|
||||
const startsCloud = isCloudManagementUrl(initialUrl);
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
|
||||
);
|
||||
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
// unreachable: soft warning. Continue stays enabled — user can confirm
|
||||
// they typed it right and proceed (matches self-hosted-behind-internal-
|
||||
// DNS / VPN scenarios where the in-app fetch would false-negative).
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
|
||||
// Continue is no longer disabled for an empty / invalid self-hosted
|
||||
// URL; a Continue click in that state focuses the input and renders
|
||||
// an inline error so the user actively notices what's missing.
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Reset inline error/warning whenever the user edits the URL or flips
|
||||
// mode — otherwise the warning lingers next to a just-corrected value.
|
||||
useEffect(() => {
|
||||
setSyntaxError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (checking) return;
|
||||
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
|
||||
// Empty or syntactically invalid URL — Continue stays enabled
|
||||
// so the click registers; surface the error inline and focus
|
||||
// the input so the user has somewhere to fix it.
|
||||
setSyntaxError(t("welcome.management.urlInvalid"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
mode === ManagementMode.Cloud
|
||||
? CLOUD_MANAGEMENT_URL
|
||||
: normalizeManagementUrl(trimmedUrl);
|
||||
if (mode === ManagementMode.SelfHosted) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
// First failed check: show soft warning + bail. A second click
|
||||
// with the same URL skips the check (unreachable still true)
|
||||
// so the user can proceed if they're sure.
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onContinue(target);
|
||||
} catch (e) {
|
||||
// Parent surfaces save errors via errorDialog; keep a console
|
||||
// breadcrumb but don't double-render.
|
||||
console.error("save management url:", e);
|
||||
}
|
||||
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
|
||||
|
||||
const inputError = useMemo(() => {
|
||||
if (syntaxError) return syntaxError;
|
||||
if (unreachable) return t("welcome.management.urlUnreachable");
|
||||
return undefined;
|
||||
}, [syntaxError, unreachable, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
||||
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
|
||||
<DialogDescription align={"left"}>
|
||||
{t("welcome.management.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable w-full"}>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
|
||||
</div>
|
||||
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"wails-no-draggable w-full text-left"}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("welcome.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={inputError}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={handleContinue}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? t("welcome.management.checking") : t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx
Normal file
61
client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { isMacOS, isWindows } from "@/lib/platform";
|
||||
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
|
||||
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
|
||||
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
|
||||
|
||||
// trayScreenshotForOS picks the marketing screenshot that shows the
|
||||
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
|
||||
// matches the chrome the user will actually be hunting for. Evaluated
|
||||
// inside the component so initPlatform() has finished by the time
|
||||
// isMacOS/isWindows run (the static imports above only load the bytes,
|
||||
// no platform check).
|
||||
function trayScreenshotForOS(): string {
|
||||
if (isMacOS()) return trayScreenshotDarwin;
|
||||
if (isWindows()) return trayScreenshotWindows;
|
||||
return trayScreenshotLinux;
|
||||
}
|
||||
|
||||
type WelcomeStepTrayProps = {
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"px-1.5"}>
|
||||
<img
|
||||
src={trayScreenshot}
|
||||
alt={""}
|
||||
className={"w-full h-auto select-none pointer-events-none rounded-2xl"}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col w-full gap-1"}>
|
||||
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
tabIndex={0}
|
||||
className={"w-full"}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
|
||||
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
|
||||
@@ -110,11 +110,16 @@
|
||||
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
|
||||
"profile.dialog.placeholder": "z. B. Arbeit",
|
||||
|
||||
"profile.switch.title": "Profil wechseln",
|
||||
"profile.switch.message": "Zu \"{name}\" wechseln? Ihr aktuelles Profil wird getrennt.",
|
||||
"profile.switch.confirm": "Wechseln",
|
||||
"profile.deregister.title": "Profil abmelden",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie \"{name}\" abmelden möchten? Sie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.deregister.confirm": "Abmelden",
|
||||
"profile.delete.title": "Profil löschen",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.delete.disabledActive": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen.",
|
||||
"profile.delete.disabledDefault": "Das Standardprofil kann nicht gelöscht werden.",
|
||||
"profile.error.switchTitle": "Profilwechsel fehlgeschlagen",
|
||||
"profile.error.deregisterTitle": "Abmeldung fehlgeschlagen",
|
||||
"profile.error.deleteTitle": "Löschen des Profils fehlgeschlagen",
|
||||
@@ -207,6 +212,7 @@
|
||||
"settings.advanced.interfaceName.errorMac": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100).",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Gib einen Port zwischen {min} und {max} ein.",
|
||||
"settings.advanced.port.help": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Gib eine MTU zwischen {min} und {max} ein.",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
@@ -308,6 +314,23 @@
|
||||
"window.title.sessionExpired": "Sitzung abgelaufen",
|
||||
"window.title.sessionExpiring": "Sitzung läuft ab",
|
||||
"window.title.updating": "Aktualisierung",
|
||||
"window.title.welcome": "Willkommen bei NetBird",
|
||||
|
||||
"welcome.title": "Suchen Sie NetBird in der Taskleiste",
|
||||
"welcome.description": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen.",
|
||||
"welcome.continue": "Weiter",
|
||||
"welcome.back": "Zurück",
|
||||
"welcome.management.title": "NetBird einrichten",
|
||||
"welcome.management.description": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie Self-hosted, wenn Sie einen eigenen NetBird-Server haben.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig.",
|
||||
"welcome.management.selfHosted.title": "Selbst gehostet",
|
||||
"welcome.management.selfHosted.description": "Verbindung zu Ihrem eigenen Management-Server.",
|
||||
"welcome.management.urlLabel": "URL des Management-Servers",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Wir konnten diesen Server nicht erreichen. Überprüfen Sie die URL oder Ihr Netzwerk — Sie können trotzdem fortfahren, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"welcome.management.checking": "Wird geprüft …",
|
||||
|
||||
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
|
||||
"browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?",
|
||||
@@ -320,10 +343,10 @@
|
||||
"sessionExpired.signIn": "Anmelden",
|
||||
|
||||
"sessionAboutToExpire.title": "Sitzung läuft bald ab",
|
||||
"sessionAboutToExpire.titleLater": "Sitzung läuft ab",
|
||||
"sessionAboutToExpire.description": "Ihre NetBird-Sitzung läuft in Kürze ab. Bleiben Sie verbunden, damit Ihre Geräte online bleiben.",
|
||||
"sessionAboutToExpire.descriptionLater": "Ihre NetBird-Sitzung läuft ab. Verlängern Sie jetzt, damit Ihre Geräte online bleiben.",
|
||||
"sessionAboutToExpire.stay": "Verbunden bleiben",
|
||||
"sessionAboutToExpire.titleLater": "Ihre Sitzung läuft ab",
|
||||
"sessionAboutToExpire.description": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich.",
|
||||
"sessionAboutToExpire.descriptionLater": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden.",
|
||||
"sessionAboutToExpire.stay": "Sitzung erneuern",
|
||||
"sessionAboutToExpire.logout": "Abmelden",
|
||||
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
|
||||
@@ -355,7 +378,6 @@
|
||||
"peers.status.disconnected": "Getrennt",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Ressourcen",
|
||||
"peers.details.more": "+{count} weitere",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass aktiviert",
|
||||
@@ -368,7 +390,6 @@
|
||||
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
|
||||
"networks.selected": "Ausgewählt",
|
||||
"networks.unselected": "Nicht ausgewählt",
|
||||
"networks.ips.more": "+{count} weitere",
|
||||
"networks.ips.heading": "Aufgelöste IPs",
|
||||
"networks.bulk.selectionCount": "{selected} von {total} aktiv",
|
||||
"networks.bulk.enableAll": "Alle aktivieren",
|
||||
@@ -378,6 +399,11 @@
|
||||
"exitNodes.none": "Keiner",
|
||||
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
|
||||
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Aktiv",
|
||||
"exitNodes.card.statusInactive": "Inaktiv",
|
||||
"exitNodes.dropdown.noneTitle": "Keiner",
|
||||
"exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node",
|
||||
|
||||
"quickActions.connect": "Verbinden",
|
||||
"quickActions.disconnect": "Trennen",
|
||||
|
||||
@@ -105,23 +105,29 @@
|
||||
"profile.selector.moreOptions": "More options",
|
||||
"profile.selector.deregister": "Deregister",
|
||||
"profile.selector.delete": "Delete",
|
||||
"profile.selector.switchTo": "Switch to this profile",
|
||||
|
||||
"profile.dialog.title": "Enter Profile Name",
|
||||
"profile.dialog.description": "Choose a memorable name.",
|
||||
"profile.dialog.placeholder": "e.g. Work",
|
||||
"profile.dialog.placeholder": "e.g. work",
|
||||
"profile.dialog.submit": "Add Profile",
|
||||
"profile.dialog.required": "Please enter a profile name, e.g. Work, Home",
|
||||
"profile.dialog.required": "Please enter a profile name, e.g. work, home",
|
||||
|
||||
"header.menu.settings": "Settings...",
|
||||
"header.menu.defaultView": "Default View",
|
||||
"header.menu.advancedView": "Advanced View",
|
||||
"header.menu.updateAvailable": "Update Available",
|
||||
|
||||
"profile.switch.title": "Switch Profile",
|
||||
"profile.switch.message": "Switch to \"{name}\"? Your current profile will be disconnected.",
|
||||
"profile.switch.confirm": "Switch",
|
||||
"profile.deregister.title": "Deregister Profile",
|
||||
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
|
||||
"profile.deregister.confirm": "Deregister",
|
||||
"profile.delete.title": "Delete Profile",
|
||||
"profile.delete.message": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"profile.delete.disabledActive": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.",
|
||||
"profile.delete.disabledDefault": "The default profile cannot be deleted.",
|
||||
"profile.error.switchTitle": "Switch Profile Failed",
|
||||
"profile.error.deregisterTitle": "Deregister Profile Failed",
|
||||
"profile.error.deleteTitle": "Delete Profile Failed",
|
||||
@@ -226,7 +232,10 @@
|
||||
"settings.advanced.section.security": "Security",
|
||||
"settings.advanced.interfaceName.label": "Name",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Enter a port between {min} and {max}.",
|
||||
"settings.advanced.port.help": "If set to 0, a random free port will be used.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Enter an MTU value between {min} and {max}.",
|
||||
"settings.advanced.psk.label": "Pre-shared Key",
|
||||
"settings.advanced.psk.help": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.",
|
||||
|
||||
@@ -245,7 +254,7 @@
|
||||
"settings.troubleshooting.duration.suffix": "Minute(s)",
|
||||
"settings.troubleshooting.create": "Create Bundle",
|
||||
"settings.troubleshooting.progress.description": "Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes.",
|
||||
"settings.troubleshooting.cancelling": "Cancelling…",
|
||||
"settings.troubleshooting.cancelling": "Canceling…",
|
||||
"settings.troubleshooting.done.uploadedTitle": "Debug bundle successfully uploaded!",
|
||||
"settings.troubleshooting.done.savedTitle": "Bundle saved",
|
||||
"settings.troubleshooting.done.uploadedDescription": "Share the upload key below with NetBird support. A local copy was also saved on your device.",
|
||||
@@ -261,7 +270,7 @@
|
||||
"settings.troubleshooting.stage.restoring": "Restoring previous log level…",
|
||||
"settings.troubleshooting.stage.bundling": "Generating debug bundle…",
|
||||
"settings.troubleshooting.stage.uploading": "Uploading to NetBird…",
|
||||
"settings.troubleshooting.stage.cancelling": "Cancelling…",
|
||||
"settings.troubleshooting.stage.cancelling": "Canceling…",
|
||||
|
||||
"settings.about.client": "NetBird Client v{version}",
|
||||
"settings.about.clientName": "NetBird Client",
|
||||
@@ -326,6 +335,23 @@
|
||||
"window.title.sessionExpired": "Session Expired",
|
||||
"window.title.sessionExpiring": "Session Expiring",
|
||||
"window.title.updating": "Updating",
|
||||
"window.title.welcome": "Welcome to NetBird",
|
||||
|
||||
"welcome.title": "Look for NetBird in your tray",
|
||||
"welcome.description": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"welcome.continue": "Continue",
|
||||
"welcome.back": "Back",
|
||||
"welcome.management.title": "Set up NetBird",
|
||||
"welcome.management.description": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Use our hosted service. No setup required.",
|
||||
"welcome.management.selfHosted.title": "Self-hosted",
|
||||
"welcome.management.selfHosted.description": "Connect to your own management server.",
|
||||
"welcome.management.urlLabel": "Management server URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "We couldn't reach this server. Check the URL or your network and try again — you can also continue if you're sure it's correct.",
|
||||
"welcome.management.checking": "Checking…",
|
||||
|
||||
"browserLogin.title": "Continue in your browser to complete the login",
|
||||
"browserLogin.notSeeing": "Not seeing the browser tab?",
|
||||
@@ -337,10 +363,10 @@
|
||||
"sessionExpired.signIn": "Sign in",
|
||||
|
||||
"sessionAboutToExpire.title": "Session expiring soon",
|
||||
"sessionAboutToExpire.titleLater": "Session will expire",
|
||||
"sessionAboutToExpire.description": "Your NetBird session will expire shortly. Stay connected to keep your devices online.",
|
||||
"sessionAboutToExpire.descriptionLater": "Your NetBird session will expire. Extend now to keep your devices online.",
|
||||
"sessionAboutToExpire.stay": "Stay connected",
|
||||
"sessionAboutToExpire.titleLater": "Your session will expire",
|
||||
"sessionAboutToExpire.description": "This device will disconnect soon. Renew with a browser sign-in.",
|
||||
"sessionAboutToExpire.descriptionLater": "A browser sign-in keeps this device connected to your network.",
|
||||
"sessionAboutToExpire.stay": "Renew session",
|
||||
"sessionAboutToExpire.logout": "Logout",
|
||||
"sessionAboutToExpire.expired": "Session expired",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
|
||||
@@ -371,7 +397,6 @@
|
||||
"peers.status.disconnected": "Disconnected",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Resources",
|
||||
"peers.details.more": "+{count} more",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass enabled",
|
||||
@@ -384,7 +409,6 @@
|
||||
"networks.empty.description": "No routed networks have been shared with this peer.",
|
||||
"networks.selected": "Selected",
|
||||
"networks.unselected": "Not selected",
|
||||
"networks.ips.more": "+{count} more",
|
||||
"networks.ips.heading": "Resolved IPs",
|
||||
"networks.bulk.selectionCount": "{selected} of {total} Active",
|
||||
"networks.bulk.enableAll": "Enable all",
|
||||
@@ -394,6 +418,11 @@
|
||||
"exitNodes.none": "None",
|
||||
"exitNodes.empty.title": "No exit nodes available",
|
||||
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Active",
|
||||
"exitNodes.card.statusInactive": "Inactive",
|
||||
"exitNodes.dropdown.noneTitle": "None",
|
||||
"exitNodes.dropdown.noneDescription": "Direct connection without an exit node",
|
||||
|
||||
"quickActions.connect": "Connect",
|
||||
"quickActions.disconnect": "Disconnect",
|
||||
|
||||
@@ -110,11 +110,16 @@
|
||||
"profile.dialog.description": "A profilok lehetővé teszik, hogy különálló NetBird-kapcsolatokat tartson egymás mellett. Adjon profiljának egy könnyen megjegyezhető nevet.",
|
||||
"profile.dialog.placeholder": "pl. Munka",
|
||||
|
||||
"profile.switch.title": "Profilváltás",
|
||||
"profile.switch.message": "Átvált erre: \"{name}\"? Az aktuális profilja le lesz választva.",
|
||||
"profile.switch.confirm": "Váltás",
|
||||
"profile.deregister.title": "Profil leválasztása",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani a következőt: \"{name}\"? Újra be kell jelentkeznie a használatához.",
|
||||
"profile.deregister.confirm": "Leválasztás",
|
||||
"profile.delete.title": "Profil törlése",
|
||||
"profile.delete.message": "Biztosan törölni szeretné a következőt: \"{name}\"? Ez a művelet nem vonható vissza.",
|
||||
"profile.delete.disabledActive": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt.",
|
||||
"profile.delete.disabledDefault": "Az alapértelmezett profil nem törölhető.",
|
||||
"profile.error.switchTitle": "Profilváltás sikertelen",
|
||||
"profile.error.deregisterTitle": "Leválasztás sikertelen",
|
||||
"profile.error.deleteTitle": "Profil törlése sikertelen",
|
||||
@@ -207,6 +212,7 @@
|
||||
"settings.advanced.interfaceName.errorMac": "„utun” után számmal kezdődjön (pl. utun100).",
|
||||
"settings.advanced.port.label": "Port",
|
||||
"settings.advanced.port.error": "Adj meg egy portot {min} és {max} között.",
|
||||
"settings.advanced.port.help": "Ha 0-ra állítod, egy véletlenszerű szabad portot használ.",
|
||||
"settings.advanced.mtu.label": "MTU",
|
||||
"settings.advanced.mtu.error": "Adj meg egy MTU értéket {min} és {max} között.",
|
||||
"settings.advanced.psk.label": "Pre-shared kulcs",
|
||||
@@ -308,6 +314,23 @@
|
||||
"window.title.sessionExpired": "Munkamenet lejárt",
|
||||
"window.title.sessionExpiring": "Munkamenet lejár",
|
||||
"window.title.updating": "Frissítés",
|
||||
"window.title.welcome": "Üdvözli a NetBird",
|
||||
|
||||
"welcome.title": "Keresse a NetBirdöt a tálcán",
|
||||
"welcome.description": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához.",
|
||||
"welcome.continue": "Folytatás",
|
||||
"welcome.back": "Vissza",
|
||||
"welcome.management.title": "NetBird beállítása",
|
||||
"welcome.management.description": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a Self-hosted lehetőséget, ha saját NetBird-szervere van.",
|
||||
"welcome.management.cloud.title": "NetBird Cloud",
|
||||
"welcome.management.cloud.description": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra.",
|
||||
"welcome.management.selfHosted.title": "Saját üzemeltetésű",
|
||||
"welcome.management.selfHosted.description": "Csatlakozás a saját menedzsmentszerveréhez.",
|
||||
"welcome.management.urlLabel": "Menedzsmentszerver URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Nem sikerült elérni a szervert. Ellenőrizze az URL-t vagy a hálózatot — ha biztos benne, hogy helyes, folytathatja.",
|
||||
"welcome.management.checking": "Ellenőrzés…",
|
||||
|
||||
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
|
||||
"browserLogin.notSeeing": "Nem látja a böngésző fülét?",
|
||||
@@ -320,10 +343,10 @@
|
||||
"sessionExpired.signIn": "Bejelentkezés",
|
||||
|
||||
"sessionAboutToExpire.title": "A munkamenet hamarosan lejár",
|
||||
"sessionAboutToExpire.titleLater": "A munkamenet lejár",
|
||||
"sessionAboutToExpire.description": "A NetBird munkamenete hamarosan lejár. Maradjon csatlakoztatva, hogy az eszközei elérhetők maradjanak.",
|
||||
"sessionAboutToExpire.descriptionLater": "A NetBird munkamenete lejár. Hosszabbítsa meg most, hogy az eszközei elérhetők maradjanak.",
|
||||
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
|
||||
"sessionAboutToExpire.titleLater": "A munkamenete lejár",
|
||||
"sessionAboutToExpire.description": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell.",
|
||||
"sessionAboutToExpire.descriptionLater": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt.",
|
||||
"sessionAboutToExpire.stay": "Munkamenet megújítása",
|
||||
"sessionAboutToExpire.logout": "Kijelentkezés",
|
||||
"sessionAboutToExpire.expired": "Munkamenet lejárt",
|
||||
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
|
||||
@@ -355,7 +378,6 @@
|
||||
"peers.status.disconnected": "Lecsatlakozva",
|
||||
"peers.details.relayAddress": "Relay",
|
||||
"peers.details.networks": "Erőforrások",
|
||||
"peers.details.more": "+{count} további",
|
||||
"peers.details.relayed": "Relayed",
|
||||
"peers.details.p2p": "P2P",
|
||||
"peers.details.rosenpass": "Rosenpass engedélyezve",
|
||||
@@ -368,7 +390,6 @@
|
||||
"networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
|
||||
"networks.selected": "Kiválasztva",
|
||||
"networks.unselected": "Nincs kiválasztva",
|
||||
"networks.ips.more": "+{count} további",
|
||||
"networks.ips.heading": "Feloldott IP-címek",
|
||||
"networks.bulk.selectionCount": "{selected} / {total} aktív",
|
||||
"networks.bulk.enableAll": "Összes engedélyezése",
|
||||
@@ -378,6 +399,11 @@
|
||||
"exitNodes.none": "Egyik sem",
|
||||
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
|
||||
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
|
||||
"exitNodes.card.title": "Kilépő csomópont",
|
||||
"exitNodes.card.statusActive": "Aktív",
|
||||
"exitNodes.card.statusInactive": "Inaktív",
|
||||
"exitNodes.dropdown.noneTitle": "Egyik sem",
|
||||
"exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül",
|
||||
|
||||
"quickActions.connect": "Csatlakozás",
|
||||
"quickActions.disconnect": "Bontás",
|
||||
|
||||
@@ -148,10 +148,20 @@ func main() {
|
||||
// (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close
|
||||
// so they don't linger as hidden windows that Wails's macOS dock-reopen
|
||||
// handler would pop back up.
|
||||
windowManager := services.NewWindowManager(app, window, bundle, prefStore)
|
||||
windowManager.WatchLanguage(prefStore)
|
||||
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
|
||||
app.RegisterService(application.NewService(windowManager))
|
||||
|
||||
// Welcome / onboarding window. First launch only — the Continue
|
||||
// button in the dialog flips OnboardingCompleted=true via the
|
||||
// Preferences service before closing, so subsequent launches skip
|
||||
// straight to the tray-only flow. ApplicationStarted hook so the
|
||||
// Wails window machinery is fully up before the window is created.
|
||||
if !prefStore.Get().OnboardingCompleted {
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
windowManager.OpenWelcome()
|
||||
})
|
||||
}
|
||||
|
||||
// Register an in-process StatusNotifierWatcher so the tray works on
|
||||
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
|
||||
// AppIndicator extension) that don't ship one themselves. No-op on
|
||||
@@ -316,7 +326,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
Width: initialWidth,
|
||||
Height: 640,
|
||||
Height: services.WindowHeight,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
URL: "/",
|
||||
|
||||
@@ -68,8 +68,9 @@ func (v ViewMode) IsValid() bool {
|
||||
// frontend. Pointer-free because the whole document is rewritten on every
|
||||
// change — there are no per-field partial updates.
|
||||
type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
}
|
||||
|
||||
// LanguageValidator is the dependency Store needs to reject SetLanguage
|
||||
@@ -162,6 +163,28 @@ func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-window dismissal so the
|
||||
// welcome flow doesn't run again on subsequent launches. Idempotent — a
|
||||
// repeat of the current value is a no-op (no disk write, no broadcast).
|
||||
func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.OnboardingCompleted == done {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.OnboardingCompleted = done
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates and persists a new language preference, then
|
||||
// broadcasts the change to internal subscribers (tray) and the emitter
|
||||
// (frontend).
|
||||
|
||||
@@ -46,6 +46,21 @@ const (
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// MetadataKindProfileListChanged is the SystemEvent.metadata["kind"]
|
||||
// marker the daemon stamps on the INFO/SYSTEM event it publishes after a
|
||||
// CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated
|
||||
// profile RPC event). dispatchSystemEvent recognises it and re-emits the
|
||||
// existing EventProfileChanged so the tray and React profile views refresh
|
||||
// — closing the gap the SubscribeStatus path can't, since a profile
|
||||
// add/remove doesn't change the daemon's status string (the tray's
|
||||
// iconChanged guard would swallow it). The daemon side hard-codes the same
|
||||
// string literal in client/server/server.go (client/server cannot import
|
||||
// this UI package).
|
||||
MetadataKindProfileListChanged = "profile-list-changed"
|
||||
// metadataKindKey is the SystemEvent.metadata key the "kind" marker lives
|
||||
// under. Kept in sync with the daemon-side literal in client/server.
|
||||
metadataKindKey = "kind"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
// permission, etc.). Real daemon statuses come straight from
|
||||
@@ -526,6 +541,16 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
|
||||
func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
// A CLI-driven profile add/remove publishes a marked SYSTEM event purely
|
||||
// to nudge the UI's profile views. Translate it into the existing
|
||||
// EventProfileChanged (which the tray's loadProfiles and React's
|
||||
// ProfileContext.refresh already subscribe to) and stop — it's an internal
|
||||
// refresh signal, not a user-facing notification, so it must not reach the
|
||||
// Recent Events list or fire an OS toast.
|
||||
if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged {
|
||||
s.emitter.Emit(EventProfileChanged, ProfileRef{})
|
||||
return
|
||||
}
|
||||
s.emitter.Emit(EventDaemonNotification, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
|
||||
@@ -36,3 +36,8 @@ func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) err
|
||||
func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error {
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-flow dismissal flag.
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ const EventSettingsOpen = "netbird:settings:open"
|
||||
// at #181A1D / nb-gray-950, used by AppLayout's <html> background).
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29)
|
||||
|
||||
// WindowHeight is the shared frame height for the main window and the
|
||||
// Settings window so the right panel inside both ends up the same size.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21);
|
||||
// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only
|
||||
@@ -88,6 +92,17 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// LinuxAppearanceOptions is the per-window Linux chrome shared by every
|
||||
// NetBird webview window. Icon shows up in the WM task list / minimised
|
||||
// state; WindowIsTranslucent is left off so the opaque background colour
|
||||
// paints reliably on compositors that fake translucency badly.
|
||||
func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
return application.LinuxWindow{
|
||||
Icon: icon,
|
||||
WindowIsTranslucent: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window
|
||||
// (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress).
|
||||
// All four share size (360x320), the no-resize / no-min / no-max chrome,
|
||||
@@ -96,7 +111,7 @@ func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
// this), and the shared background/Mac/Windows appearance. Callers fill
|
||||
// in per-dialog overrides (URL params, screen targeting, etc.) on the
|
||||
// returned value before passing it to Window.NewWithOptions.
|
||||
func DialogWindowOptions(name, title, url string) application.WebviewWindowOptions {
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
@@ -112,6 +127,7 @@ func DialogWindowOptions(name, title, url string) application.WebviewWindowOptio
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +148,13 @@ type WindowManager struct {
|
||||
mainWindow *application.WebviewWindow
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
linuxIcon []byte
|
||||
settings *application.WebviewWindow
|
||||
browserLogin *application.WebviewWindow
|
||||
sessionExpired *application.WebviewWindow
|
||||
sessionAboutToExpire *application.WebviewWindow
|
||||
installProgress *application.WebviewWindow
|
||||
welcome *application.WebviewWindow
|
||||
// hiddenForLogin remembers windows that were visible when the
|
||||
// BrowserLogin popup opened. They were Hide()n to keep focus on the
|
||||
// SSO flow without resorting to AlwaysOnTop, and are restored when
|
||||
@@ -173,13 +191,33 @@ func (s *WindowManager) title(key string) string {
|
||||
// The Settings window is created here, hidden, so the first OpenSettings
|
||||
// call paints instantly instead of paying webview construction + asset load
|
||||
// at click time.
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs}
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
|
||||
// If the prefs implementation also exposes Subscribe (the runtime
|
||||
// *preferences.Store does), wire up a goroutine that re-titles every
|
||||
// live auxiliary window on language flip. Done here — instead of via
|
||||
// an exported WatchLanguage method on the service — so the Wails
|
||||
// binding generator doesn't try to expose a LanguageSubscriber-taking
|
||||
// method to the frontend (interface params can't round-trip through
|
||||
// JSON and would emit a generator warning).
|
||||
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
|
||||
ch, _ := sub.Subscribe()
|
||||
go func() {
|
||||
var last i18n.LanguageCode
|
||||
for p := range ch {
|
||||
if p.Language == "" || p.Language == last {
|
||||
continue
|
||||
}
|
||||
last = p.Language
|
||||
s.retitleAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
Height: 640,
|
||||
Height: WindowHeight,
|
||||
Hidden: true,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
@@ -187,8 +225,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
})
|
||||
// Hide on close instead of destroying — preserves in-window React state
|
||||
// across reopens. Mirrors the main window's close behaviour. Resetting
|
||||
@@ -203,31 +242,6 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
return s
|
||||
}
|
||||
|
||||
// WatchLanguage subscribes to UI preference changes and re-applies the
|
||||
// localised title to every live auxiliary window whenever the language
|
||||
// flips. The eagerly-created Settings window outlives its first paint, so
|
||||
// without this its title would stay frozen in the language it was created
|
||||
// in; the on-demand dialog windows (BrowserLogin, Session*, InstallProgress)
|
||||
// can also coexist with a Settings-driven language change. Safe to call
|
||||
// once at startup; subsequent calls overwrite the previous subscription.
|
||||
// No-op when sub is nil.
|
||||
func (s *WindowManager) WatchLanguage(sub LanguageSubscriber) {
|
||||
if sub == nil {
|
||||
return
|
||||
}
|
||||
ch, _ := sub.Subscribe()
|
||||
go func() {
|
||||
var last i18n.LanguageCode
|
||||
for p := range ch {
|
||||
if p.Language == "" || p.Language == last {
|
||||
continue
|
||||
}
|
||||
last = p.Language
|
||||
s.retitleAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// retitleAll re-applies the localised title to every currently-alive
|
||||
// auxiliary window. Reads the window pointers under s.mu so a concurrent
|
||||
// Open*/Close* can't observe a torn slice. SetTitle itself dispatches to
|
||||
@@ -244,6 +258,7 @@ func (s *WindowManager) retitleAll() {
|
||||
{s.sessionExpired, "window.title.sessionExpired"},
|
||||
{s.sessionAboutToExpire, "window.title.sessionExpiring"},
|
||||
{s.installProgress, "window.title.updating"},
|
||||
{s.welcome, "window.title.welcome"},
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, p := range wins {
|
||||
@@ -295,7 +310,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL)
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// SSO popup deliberately is NOT always-on-top — the user moves
|
||||
// between the browser tab and our popup; pinning it would obscure
|
||||
// the browser at the moment they need to interact with it.
|
||||
@@ -405,7 +420,7 @@ func (s *WindowManager) OpenSessionExpired() {
|
||||
defer s.mu.Unlock()
|
||||
if s.sessionExpired == nil {
|
||||
s.sessionExpired = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired"),
|
||||
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired", s.linuxIcon),
|
||||
)
|
||||
s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -440,7 +455,7 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
|
||||
startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
|
||||
if s.sessionAboutToExpire == nil {
|
||||
s.sessionAboutToExpire = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL),
|
||||
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -486,7 +501,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
if s.installProgress == nil {
|
||||
s.hideOtherWindowsLocked("install-progress")
|
||||
s.installProgress = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL),
|
||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
@@ -511,3 +526,57 @@ func (s *WindowManager) CloseInstallProgress() {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenWelcome shows the first-launch onboarding window. The React side
|
||||
// auto-sizes the window height to its content; the Continue button calls
|
||||
// Preferences.SetOnboardingCompleted(true) before closing so the flow
|
||||
// doesn't re-run. Singleton, destroyed on close. Created Hidden so the
|
||||
// React side can auto-size before paint.
|
||||
func (s *WindowManager) OpenWelcome() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.welcome == nil {
|
||||
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
|
||||
opts.Width = 420
|
||||
// Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions)
|
||||
// so the user can't accidentally bury the first-launch flow behind
|
||||
// another window and lose track of how to finish setup.
|
||||
// Land in the middle of the user's primary display — the welcome
|
||||
// flow is identity-defining and shouldn't read as an incidental
|
||||
// dialog floating in a corner. WindowCentered + nil Screen
|
||||
// resolves against the primary display (see WebviewWindowOptions).
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
s.welcome = s.app.Window.NewWithOptions(opts)
|
||||
w := s.welcome
|
||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return
|
||||
}
|
||||
s.welcome.Show()
|
||||
s.welcome.Focus()
|
||||
}
|
||||
|
||||
// CloseWelcome destroys the welcome window if open.
|
||||
func (s *WindowManager) CloseWelcome() {
|
||||
s.mu.Lock()
|
||||
w := s.welcome
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenMain brings the main window forward. Used by the welcome Continue
|
||||
// button to hand off from onboarding to the regular UI without depending
|
||||
// on the tray.
|
||||
func (s *WindowManager) OpenMain() {
|
||||
if s.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
s.mainWindow.Show()
|
||||
s.mainWindow.Focus()
|
||||
}
|
||||
|
||||
@@ -215,17 +215,18 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
}
|
||||
t.menu = t.buildMenu()
|
||||
t.tray.SetMenu(t.menu)
|
||||
// Tray click handling is platform-specific (see the tray_click_*.go
|
||||
// files): macOS auto-shows the menu on left-click natively, so its
|
||||
// bindTrayClick is a no-op (binding OnClick→OpenMenu would freeze the
|
||||
// tray — see tray_click_other.go). Windows has no native left-click
|
||||
// handler, so it wires left→OpenMenu + double→ShowWindow. Linux hosts
|
||||
// disagree on left-click (KDE routes it to Activate, which was unwired
|
||||
// and appeared dead), so Linux binds left→ShowWindow. The context menu
|
||||
// stays reachable via right-click on every platform, plus the explicit
|
||||
// "Open NetBird" entry. AttachWindow is deliberately skipped everywhere:
|
||||
// with Wails3's applySmartDefaults it would pop the window alongside the
|
||||
// menu on GNOME Shell + AppIndicator.
|
||||
// Left-click on the tray icon opens the menu, and the window is reached
|
||||
// through the explicit "Open NetBird" entry. This matches macOS
|
||||
// NSStatusItem convention (click → menu), the Linux StatusNotifierItem
|
||||
// spec, and the legacy Fyne client. macOS and Linux give us click→menu
|
||||
// natively, so bindTrayClick is a no-op there (binding OnClick→OpenMenu
|
||||
// on macOS would freeze the tray — see tray_click_other.go). Windows has
|
||||
// no native left-click handler, so bindTrayClick wires one explicitly
|
||||
// (see tray_click_windows.go). On Linux we deliberately skip AttachWindow:
|
||||
// it plus Wails3's applySmartDefaults would pop the window alongside the
|
||||
// menu on environments like GNOME Shell with the AppIndicator extension.
|
||||
// Right-click opens the menu through Wails' default rightClickHandler on
|
||||
// every platform.
|
||||
bindTrayClick(t)
|
||||
|
||||
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||
//
|
||||
// Different StatusNotifierItem hosts route a left-click differently. KDE
|
||||
// Plasma maps left-click to the SNI Activate method and right-click to 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 (the
|
||||
// behaviour users reported as confusing). Wails' Linux SNI backend forwards
|
||||
// Activate to the tray's OnClick handler (systemtray_linux.go Activate →
|
||||
// clickHandler), so we bind one here.
|
||||
//
|
||||
// We open the main window rather than the menu. OpenMenu() is not an option
|
||||
// on Linux: the Wails v3 backend leaves linuxSystemTray.openMenu unimplemented
|
||||
// (it only logs), so a left-click→OpenMenu binding would still do nothing on
|
||||
// KDE. ShowWindow() is the same call Windows already runs from its
|
||||
// double-click handler, so it is a proven-safe click-handler action — and it
|
||||
// does not reproduce the macOS OpenMenu freeze (commit c77e5cef8): that freeze
|
||||
// came from NSStatusItem's blocking embedded menu loop, whereas Show/Focus
|
||||
// return immediately. The context menu stays reachable via right-click through
|
||||
// the host's own rendering.
|
||||
//
|
||||
// On hosts where left-click already opens the menu natively (e.g. GNOME Shell
|
||||
// with the AppIndicator extension) this means left-click now opens the window
|
||||
// instead — the menu remains on right-click. AttachWindow is deliberately not
|
||||
// used: combined with Wails3's applySmartDefaults it pops the window alongside
|
||||
// the menu on those hosts, which is not the UX we want.
|
||||
func bindTrayClick(t *Tray) {
|
||||
t.tray.OnClick(func() { t.ShowWindow() })
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//go:build !windows && !linux && !android && !ios && !freebsd && !js
|
||||
//go:build !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
// bindTrayClick is a no-op on macOS. The native NSStatusItem auto-shows the
|
||||
// menu on left-click, so binding an OnClick→OpenMenu handler is both
|
||||
// unnecessary and actively harmful: OpenMenu routes through NSStatusItem's
|
||||
// blocking [button mouseDown:] on the serial main GCD queue and freezes the
|
||||
// tray and webview until the menu closes (commit c77e5cef8). Windows opts in
|
||||
// via tray_click_windows.go; Linux via tray_click_linux.go.
|
||||
// bindTrayClick is a no-op on macOS and Linux. On macOS the native
|
||||
// NSStatusItem auto-shows the menu on left-click; on Linux the
|
||||
// StatusNotifierItem host paints the menu independently. Binding an
|
||||
// OnClick→OpenMenu handler is both unnecessary there and actively harmful on
|
||||
// macOS, where OpenMenu routes through NSStatusItem's blocking [button
|
||||
// mouseDown:] on the serial main GCD queue and freezes the tray and webview
|
||||
// until the menu closes (commit c77e5cef8). Windows opts in via the sibling
|
||||
// tray_click_windows.go file.
|
||||
func bindTrayClick(*Tray) {}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// init runs before Wails' own init(), so the env vars are set in time.
|
||||
func init() {
|
||||
disableDMABUFRenderer()
|
||||
disableCompositingMode()
|
||||
disableWebKitSandboxIfNeeded()
|
||||
}
|
||||
|
||||
@@ -26,6 +27,23 @@ func disableDMABUFRenderer() {
|
||||
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
|
||||
}
|
||||
|
||||
// disableCompositingMode turns off WebKitGTK's accelerated (GL) compositing
|
||||
// path. Disabling the DMA-BUF renderer alone is not enough on some Intel
|
||||
// setups: WebKitGTK 2.52 still drives the GPU through the GL compositor, and
|
||||
// Mesa's anv/i965 hits unimplemented DRM-format-modifier code paths
|
||||
// ("FINISHME: support YUV colorspace with DRM format modifiers" /
|
||||
// "...multi-planar formats...") that crash with a SIGSEGV inside
|
||||
// g_application_run before the first frame paints. Forcing compositing off
|
||||
// makes WebKit render on the CPU, which is fine for a small UI like this and
|
||||
// sidesteps the broken modifier path. The user can re-enable it by setting
|
||||
// WEBKIT_DISABLE_COMPOSITING_MODE themselves (e.g. to "0").
|
||||
func disableCompositingMode() {
|
||||
if os.Getenv("WEBKIT_DISABLE_COMPOSITING_MODE") != "" {
|
||||
return
|
||||
}
|
||||
_ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1")
|
||||
}
|
||||
|
||||
// disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when
|
||||
// its bubblewrap (bwrap) sandbox can't create an unprivileged user namespace —
|
||||
// "bwrap: setting up uid map: Permission denied" followed by "Failed to fully
|
||||
|
||||
@@ -60,20 +60,23 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
|
||||
d = *deadline
|
||||
}
|
||||
|
||||
switch {
|
||||
case deadline == nil:
|
||||
log.Infof("tray applySessionExpiry: deadline=<nil> connected=%v → row hidden", connected)
|
||||
case deadline.IsZero():
|
||||
log.Infof("tray applySessionExpiry: deadline=<zero> connected=%v → row hidden", connected)
|
||||
default:
|
||||
log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v",
|
||||
deadline.Format(time.RFC3339), time.Until(*deadline), connected)
|
||||
}
|
||||
|
||||
t.sessionMu.Lock()
|
||||
changed := !t.sessionExpiresAt.Equal(d)
|
||||
t.sessionExpiresAt = d
|
||||
t.sessionMu.Unlock()
|
||||
|
||||
if changed {
|
||||
switch {
|
||||
case deadline == nil:
|
||||
log.Infof("tray applySessionExpiry: deadline=<nil> connected=%v → row hidden", connected)
|
||||
case deadline.IsZero():
|
||||
log.Infof("tray applySessionExpiry: deadline=<zero> connected=%v → row hidden", connected)
|
||||
default:
|
||||
log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v",
|
||||
deadline.Format(time.RFC3339), time.Until(*deadline), connected)
|
||||
}
|
||||
}
|
||||
|
||||
if t.sessionExpiresItem == nil {
|
||||
return
|
||||
}
|
||||
@@ -131,13 +134,13 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string {
|
||||
}
|
||||
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
|
||||
case d < 24*time.Hour:
|
||||
h := int(d / time.Hour)
|
||||
h := int((d + 30*time.Minute) / time.Hour)
|
||||
if h == 1 {
|
||||
return t.loc.T("tray.session.unit.hour")
|
||||
}
|
||||
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
|
||||
default:
|
||||
days := int(d / (24 * time.Hour))
|
||||
days := int((d + 12*time.Hour) / (24 * time.Hour))
|
||||
if days == 1 {
|
||||
return t.loc.T("tray.session.unit.day")
|
||||
}
|
||||
|
||||
14
client/ui/tray_status_enabled_linux.go
Normal file
14
client/ui/tray_status_enabled_linux.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// statusRowEnabled reports whether the informational status row at the
|
||||
// top of the tray menu should stay enabled. True on Linux: a disabled
|
||||
// row is painted greyed-out, which makes the connection-status indicator
|
||||
// at the top of the menu look washed-out. Keeping it enabled lets the
|
||||
// row (and its coloured status dot) render at full opacity. The row has
|
||||
// no OnClick handler, so clicking it is still a no-op — enabling only
|
||||
// affects how it is drawn, not its behaviour. macOS disables the row
|
||||
// (tray_status_enabled_other.go); Windows enables it for a different
|
||||
// reason (tray_status_enabled_windows.go).
|
||||
func statusRowEnabled() bool { return true }
|
||||
@@ -1,12 +1,12 @@
|
||||
//go:build !windows && !android && !ios && !freebsd && !js
|
||||
//go:build !windows && !linux && !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
// statusRowEnabled reports whether the informational status row at the
|
||||
// top of the tray menu should stay enabled. False on macOS and Linux:
|
||||
// both platforms paint disabled menu rows at slightly reduced opacity
|
||||
// without desaturating the leading bitmap, so the coloured status dot
|
||||
// stays visible while the greyed-out label still signals to the user
|
||||
// that the row is informational and not clickable. Windows opts in via
|
||||
// the sibling tray_status_enabled_windows.go file.
|
||||
// top of the tray menu should stay enabled. False on macOS: it paints
|
||||
// disabled menu rows at slightly reduced opacity without desaturating
|
||||
// the leading bitmap, so the coloured status dot stays visible while the
|
||||
// greyed-out label still signals to the user that the row is
|
||||
// informational and not clickable. Windows opts in via
|
||||
// tray_status_enabled_windows.go; Linux via tray_status_enabled_linux.go.
|
||||
func statusRowEnabled() bool { return false }
|
||||
|
||||
@@ -8,37 +8,26 @@ package main
|
||||
// setDarkModeIcon just calls setIcon, so the last write wins regardless of
|
||||
// panel theme (see pkg/application/systemtray_linux.go). The SNI spec itself
|
||||
// also carries no reliable "panel is dark/light" hint for clients. So we
|
||||
// detect the desktop's colour-scheme preference ourselves via the
|
||||
// freedesktop Settings portal (org.freedesktop.portal.Settings, the
|
||||
// org.freedesktop.appearance/color-scheme key) and pick the black or white
|
||||
// silhouette in iconForState. We also subscribe to the portal's
|
||||
// SettingChanged signal so a live theme switch repaints the icon.
|
||||
// detect the desktop's colour scheme ourselves and pick the black or white
|
||||
// silhouette in iconForState.
|
||||
//
|
||||
// This file holds the (stateless) dark/light decision helpers; the live
|
||||
// watcher that seeds and repaints on change lives in
|
||||
// tray_theme_watcher_linux.go.
|
||||
//
|
||||
// color-scheme values (per the freedesktop appearance spec):
|
||||
// 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
portalBusName = "org.freedesktop.portal.Desktop"
|
||||
portalObjectPath = "/org/freedesktop/portal/desktop"
|
||||
portalSettings = "org.freedesktop.portal.Settings"
|
||||
|
||||
appearanceNamespace = "org.freedesktop.appearance"
|
||||
colorSchemeKey = "color-scheme"
|
||||
|
||||
colorSchemeNoPreference = 0
|
||||
colorSchemePreferDark = 1
|
||||
colorSchemePreferLight = 2
|
||||
)
|
||||
|
||||
// startTrayTheme wires the Linux panel-theme watcher into the tray: it seeds
|
||||
// t.panelDark from the freedesktop Settings portal and repaints the icon on
|
||||
// every live colour-scheme flip. Called from NewTray before the first
|
||||
@@ -48,97 +37,106 @@ func (t *Tray) startTrayTheme() {
|
||||
t.panelDark = w.IsDark
|
||||
}
|
||||
|
||||
// themeWatcher reads the desktop colour-scheme preference over the session
|
||||
// bus and invokes onChange whenever it flips. It owns a private session-bus
|
||||
// connection so its signal subscription is isolated from the SNI watcher's.
|
||||
type themeWatcher struct {
|
||||
conn *dbus.Conn
|
||||
onChange func()
|
||||
|
||||
mu sync.Mutex
|
||||
darkMode bool
|
||||
// isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP
|
||||
// is a colon-separated list (e.g. "KDE", "ubuntu:KDE"), so we match the token.
|
||||
func isKDE() bool {
|
||||
for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") {
|
||||
if strings.EqualFold(strings.TrimSpace(d), "KDE") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// startThemeWatcher opens a private session-bus connection, seeds the current
|
||||
// colour scheme, and subscribes to the portal's SettingChanged signal. It
|
||||
// returns nil (and logs) if the portal is unavailable — callers treat a nil
|
||||
// watcher as "no preference", which keeps the default-dark icon choice.
|
||||
func startThemeWatcher(onChange func()) *themeWatcher {
|
||||
conn, err := dbus.SessionBusPrivate()
|
||||
// kdeglobalsPath returns the user kdeglobals path ($XDG_CONFIG_HOME/kdeglobals,
|
||||
// or ~/.config/kdeglobals), the highest-priority file in KDE's config cascade.
|
||||
// We read only this file rather than replaying the full XDG_CONFIG_DIRS +
|
||||
// kdedefaults cascade: the user file is where Plasma writes the active scheme,
|
||||
// and if the Complementary group is absent here we fall back to the portal.
|
||||
func kdeglobalsPath() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "kdeglobals")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err)
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
if err := conn.Auth(nil); err != nil {
|
||||
_ = conn.Close()
|
||||
log.Debugf("tray theme: dbus auth failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := conn.Hello(); err != nil {
|
||||
_ = conn.Close()
|
||||
log.Debugf("tray theme: dbus hello failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
w := &themeWatcher{conn: conn, onChange: onChange}
|
||||
w.darkMode = w.readDarkMode()
|
||||
|
||||
if err := w.subscribe(); err != nil {
|
||||
log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err)
|
||||
// Keep the connection: the seeded darkMode value is still useful.
|
||||
}
|
||||
|
||||
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
|
||||
return w
|
||||
return filepath.Join(home, ".config", "kdeglobals")
|
||||
}
|
||||
|
||||
// IsDark reports the last observed colour-scheme preference. A nil watcher
|
||||
// (portal unavailable) reports true so the icon defaults to the white
|
||||
// silhouette, which suits the common dark Linux panel.
|
||||
func (w *themeWatcher) IsDark() bool {
|
||||
if w == nil {
|
||||
return true
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel is dark, reading the
|
||||
// Breeze "Complementary" background — the colour Plasma actually paints the
|
||||
// panel/system-tray with — from kdeglobals and deciding by its luma. The
|
||||
// second return is false when this isn't KDE or the colour can't be read, so
|
||||
// readDarkMode falls through to the portal/GTK path.
|
||||
func kdePanelIsDark() (dark, ok bool) {
|
||||
if !isKDE() {
|
||||
return false, false
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.darkMode
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
}
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
|
||||
// readDarkMode resolves the current dark/light preference. The freedesktop
|
||||
// color-scheme portal is the primary source; when it is unavailable or
|
||||
// reports "no preference" (0), we fall back to the GTK_THEME env var (the
|
||||
// GTK convention appends ":dark" for the dark variant, e.g. "Adwaita:dark").
|
||||
// If neither yields a signal we default to dark, matching the common dark
|
||||
// Linux panel.
|
||||
func (w *themeWatcher) readDarkMode() bool {
|
||||
switch w.readColorScheme() {
|
||||
case colorSchemePreferDark:
|
||||
return true
|
||||
case colorSchemePreferLight:
|
||||
return false
|
||||
default: // colorSchemeNoPreference or portal unavailable
|
||||
return gtkThemeIsDark()
|
||||
// readKdeComplementaryBackground parses kdeglobals for
|
||||
// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
|
||||
func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
|
||||
return rgb, false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
const group = "[Colors:Complementary]"
|
||||
inGroup := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inGroup = line == group
|
||||
continue
|
||||
}
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(key) != "BackgroundNormal" {
|
||||
continue
|
||||
}
|
||||
return parseRGB(strings.TrimSpace(val))
|
||||
}
|
||||
return rgb, false
|
||||
}
|
||||
|
||||
// readColorScheme returns the raw freedesktop color-scheme value (0 = no
|
||||
// preference, 1 = prefer dark, 2 = prefer light), or colorSchemeNoPreference
|
||||
// when the portal can't be reached.
|
||||
func (w *themeWatcher) readColorScheme() uint32 {
|
||||
obj := w.conn.Object(portalBusName, portalObjectPath)
|
||||
call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey)
|
||||
if call.Err != nil {
|
||||
log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err)
|
||||
return colorSchemeNoPreference
|
||||
// parseRGB parses a "r,g,b" triple (KDE's colour format) into bytes.
|
||||
func parseRGB(s string) (rgb [3]uint8, ok bool) {
|
||||
parts := strings.Split(s, ",")
|
||||
if len(parts) != 3 {
|
||||
return rgb, false
|
||||
}
|
||||
|
||||
var v dbus.Variant
|
||||
if err := call.Store(&v); err != nil {
|
||||
log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err)
|
||||
return colorSchemeNoPreference
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil || n < 0 || n > 255 {
|
||||
return rgb, false
|
||||
}
|
||||
rgb[i] = uint8(n)
|
||||
}
|
||||
return rgb, true
|
||||
}
|
||||
|
||||
return variantToColorScheme(v)
|
||||
// isDarkRGB reports whether a colour is dark using the Rec. 601 relative luma.
|
||||
// The 128 midpoint matches the perceptual split between needing a light vs a
|
||||
// dark foreground.
|
||||
func isDarkRGB(r, g, b uint8) bool {
|
||||
luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000
|
||||
return luma < 128
|
||||
}
|
||||
|
||||
// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is
|
||||
@@ -151,90 +149,3 @@ func gtkThemeIsDark() bool {
|
||||
// GTK_THEME is "Name[:variant]"; the dark variant is ":dark".
|
||||
return strings.Contains(strings.ToLower(theme), ":dark")
|
||||
}
|
||||
|
||||
// subscribe registers a match rule for the portal's SettingChanged signal and
|
||||
// spawns a goroutine that re-reads the scheme and fires onChange on each
|
||||
// relevant change.
|
||||
func (w *themeWatcher) subscribe() error {
|
||||
if err := w.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(portalObjectPath),
|
||||
dbus.WithMatchInterface(portalSettings),
|
||||
dbus.WithMatchMember("SettingChanged"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sigs := make(chan *dbus.Signal, 8)
|
||||
w.conn.Signal(sigs)
|
||||
go w.loop(sigs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loop consumes SettingChanged signals, filters to the colour-scheme key, and
|
||||
// repaints the icon when the dark/light preference actually flips.
|
||||
func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
|
||||
for sig := range sigs {
|
||||
if sig.Name != portalSettings+".SettingChanged" {
|
||||
continue
|
||||
}
|
||||
// Signal body: (namespace string, key string, value variant).
|
||||
if len(sig.Body) < 3 {
|
||||
continue
|
||||
}
|
||||
namespace, _ := sig.Body[0].(string)
|
||||
key, _ := sig.Body[1].(string)
|
||||
if namespace != appearanceNamespace || key != colorSchemeKey {
|
||||
continue
|
||||
}
|
||||
variant, ok := sig.Body[2].(dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dark := colorSchemeToDark(variantToColorScheme(variant))
|
||||
w.mu.Lock()
|
||||
changed := dark != w.darkMode
|
||||
w.darkMode = dark
|
||||
w.mu.Unlock()
|
||||
|
||||
if changed && w.onChange != nil {
|
||||
log.Infof("tray theme: panel dark mode changed to %v", dark)
|
||||
w.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// colorSchemeToDark maps a freedesktop color-scheme value to a dark/light
|
||||
// bool, deferring "no preference" (0) to the GTK_THEME fallback.
|
||||
func colorSchemeToDark(scheme uint32) bool {
|
||||
switch scheme {
|
||||
case colorSchemePreferDark:
|
||||
return true
|
||||
case colorSchemePreferLight:
|
||||
return false
|
||||
default:
|
||||
return gtkThemeIsDark()
|
||||
}
|
||||
}
|
||||
|
||||
// variantToColorScheme unwraps the color-scheme variant (the portal nests it
|
||||
// one level: a variant holding a uint32) into the raw scheme value, returning
|
||||
// colorSchemeNoPreference for an unexpected payload.
|
||||
func variantToColorScheme(v dbus.Variant) uint32 {
|
||||
inner := v.Value()
|
||||
if nested, ok := inner.(dbus.Variant); ok {
|
||||
inner = nested.Value()
|
||||
}
|
||||
|
||||
switch n := inner.(type) {
|
||||
case uint32:
|
||||
return n
|
||||
case int32:
|
||||
return uint32(n)
|
||||
case uint8:
|
||||
return uint32(n)
|
||||
default:
|
||||
log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner)
|
||||
return colorSchemeNoPreference
|
||||
}
|
||||
}
|
||||
|
||||
86
client/ui/tray_theme_linux_test.go
Normal file
86
client/ui/tray_theme_linux_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadKdeComplementaryBackground(t *testing.T) {
|
||||
// Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark.
|
||||
// The tray sits on the panel, which Plasma paints from Complementary, so
|
||||
// the panel is dark even though the global color-scheme is Light.
|
||||
content := `[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[Colors:Complementary]
|
||||
BackgroundAlternate=27,30,32
|
||||
BackgroundNormal=42,46,50
|
||||
|
||||
[General]
|
||||
ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
t.Fatal("expected to find Complementary BackgroundNormal")
|
||||
}
|
||||
if rgb != [3]uint8{42, 46, 50} {
|
||||
t.Fatalf("rgb = %v, want [42 46 50]", rgb)
|
||||
}
|
||||
if !isDarkRGB(rgb[0], rgb[1], rgb[2]) {
|
||||
t.Fatal("panel colour 42,46,50 should be dark")
|
||||
}
|
||||
// The Window background (what color-scheme reflects) is light — the bug
|
||||
// this fix addresses is picking the icon from that instead of the panel.
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("window colour 239,240,241 should be light")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readKdeComplementaryBackground(path); ok {
|
||||
t.Fatal("expected not-ok when Complementary group is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRGB(t *testing.T) {
|
||||
if _, ok := parseRGB("1,2"); ok {
|
||||
t.Fatal("two components should fail")
|
||||
}
|
||||
if _, ok := parseRGB("300,0,0"); ok {
|
||||
t.Fatal("out-of-range should fail")
|
||||
}
|
||||
if _, ok := parseRGB("a,b,c"); ok {
|
||||
t.Fatal("non-numeric should fail")
|
||||
}
|
||||
rgb, ok := parseRGB(" 10 , 20 , 30 ")
|
||||
if !ok || rgb != [3]uint8{10, 20, 30} {
|
||||
t.Fatalf("parseRGB = %v ok=%v, want [10 20 30] true", rgb, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDarkRGB(t *testing.T) {
|
||||
if !isDarkRGB(0, 0, 0) {
|
||||
t.Fatal("black is dark")
|
||||
}
|
||||
if isDarkRGB(255, 255, 255) {
|
||||
t.Fatal("white is light")
|
||||
}
|
||||
if !isDarkRGB(42, 46, 50) {
|
||||
t.Fatal("Breeze panel grey is dark")
|
||||
}
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("Breeze window grey is light")
|
||||
}
|
||||
}
|
||||
280
client/ui/tray_theme_watcher_linux.go
Normal file
280
client/ui/tray_theme_watcher_linux.go
Normal file
@@ -0,0 +1,280 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
// themeWatcher: the live half of Linux panel-theme detection. It seeds the
|
||||
// current dark/light state, then watches for changes from two sources and
|
||||
// repaints the tray icon when the panel theme flips:
|
||||
// - the freedesktop Settings portal's SettingChanged signal (the cross-
|
||||
// desktop colour-scheme source), and
|
||||
// - on KDE, the user kdeglobals file (the portal's color-scheme doesn't
|
||||
// track the panel's Complementary colour — see readDarkMode).
|
||||
//
|
||||
// The dark/light decision itself lives in tray_theme_linux.go; this file owns
|
||||
// the session-bus connection, the signal/file subscriptions, and the repaint.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/godbus/dbus/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
portalBusName = "org.freedesktop.portal.Desktop"
|
||||
portalObjectPath = "/org/freedesktop/portal/desktop"
|
||||
portalSettings = "org.freedesktop.portal.Settings"
|
||||
|
||||
appearanceNamespace = "org.freedesktop.appearance"
|
||||
colorSchemeKey = "color-scheme"
|
||||
|
||||
colorSchemeNoPreference = 0
|
||||
colorSchemePreferDark = 1
|
||||
colorSchemePreferLight = 2
|
||||
)
|
||||
|
||||
// themeWatcher reads the desktop colour-scheme preference over the session
|
||||
// bus and invokes onChange whenever it flips. It owns a private session-bus
|
||||
// connection so its signal subscription is isolated from the SNI watcher's.
|
||||
type themeWatcher struct {
|
||||
conn *dbus.Conn
|
||||
onChange func()
|
||||
|
||||
mu sync.Mutex
|
||||
darkMode bool
|
||||
}
|
||||
|
||||
// startThemeWatcher opens a private session-bus connection, seeds the current
|
||||
// colour scheme, and subscribes to the portal's SettingChanged signal. It
|
||||
// returns nil (and logs) if the portal is unavailable — callers treat a nil
|
||||
// watcher as "no preference", which keeps the default-dark icon choice.
|
||||
func startThemeWatcher(onChange func()) *themeWatcher {
|
||||
conn, err := dbus.SessionBusPrivate()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := conn.Auth(nil); err != nil {
|
||||
_ = conn.Close()
|
||||
log.Debugf("tray theme: dbus auth failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := conn.Hello(); err != nil {
|
||||
_ = conn.Close()
|
||||
log.Debugf("tray theme: dbus hello failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
w := &themeWatcher{conn: conn, onChange: onChange}
|
||||
w.darkMode = w.readDarkMode()
|
||||
|
||||
if err := w.subscribe(); err != nil {
|
||||
log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err)
|
||||
// Keep the connection: the seeded darkMode value is still useful.
|
||||
}
|
||||
|
||||
// On KDE the portal's color-scheme signal doesn't track the panel's
|
||||
// Complementary colour, so watch kdeglobals directly to repaint on a
|
||||
// theme switch.
|
||||
if isKDE() {
|
||||
w.watchKdeglobals()
|
||||
}
|
||||
|
||||
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
|
||||
return w
|
||||
}
|
||||
|
||||
// IsDark reports the last observed colour-scheme preference. A nil watcher
|
||||
// (portal unavailable) reports true so the icon defaults to the white
|
||||
// silhouette, which suits the common dark Linux panel.
|
||||
func (w *themeWatcher) IsDark() bool {
|
||||
if w == nil {
|
||||
return true
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.darkMode
|
||||
}
|
||||
|
||||
// readDarkMode resolves whether the desktop panel (where the tray icon sits)
|
||||
// is dark.
|
||||
//
|
||||
// On KDE the freedesktop color-scheme is the *application* window preference,
|
||||
// not the panel's: Plasma paints its panel and system tray from the Breeze
|
||||
// "Complementary" colour group, which stays dark even under a Light global
|
||||
// scheme (kdeglobals [Colors:Window] light vs [Colors:Complementary] dark).
|
||||
// So a light color-scheme there would wrongly pick the black silhouette,
|
||||
// which then disappears against the dark panel. We therefore read the actual
|
||||
// panel background from kdeglobals first under KDE and decide by its luma.
|
||||
//
|
||||
// Off KDE (or when kdeglobals can't be read), the freedesktop color-scheme
|
||||
// portal is the source; when it is unavailable or reports "no preference"
|
||||
// (0), we fall back to the GTK_THEME env var (the GTK convention appends
|
||||
// ":dark" for the dark variant, e.g. "Adwaita:dark"). If nothing yields a
|
||||
// signal we default to dark, matching the common dark Linux panel.
|
||||
func (w *themeWatcher) readDarkMode() bool {
|
||||
if dark, ok := kdePanelIsDark(); ok {
|
||||
return dark
|
||||
}
|
||||
switch w.readColorScheme() {
|
||||
case colorSchemePreferDark:
|
||||
return true
|
||||
case colorSchemePreferLight:
|
||||
return false
|
||||
default: // colorSchemeNoPreference or portal unavailable
|
||||
return gtkThemeIsDark()
|
||||
}
|
||||
}
|
||||
|
||||
// readColorScheme returns the raw freedesktop color-scheme value (0 = no
|
||||
// preference, 1 = prefer dark, 2 = prefer light), or colorSchemeNoPreference
|
||||
// when the portal can't be reached.
|
||||
func (w *themeWatcher) readColorScheme() uint32 {
|
||||
obj := w.conn.Object(portalBusName, portalObjectPath)
|
||||
call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey)
|
||||
if call.Err != nil {
|
||||
log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err)
|
||||
return colorSchemeNoPreference
|
||||
}
|
||||
|
||||
var v dbus.Variant
|
||||
if err := call.Store(&v); err != nil {
|
||||
log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err)
|
||||
return colorSchemeNoPreference
|
||||
}
|
||||
|
||||
return variantToColorScheme(v)
|
||||
}
|
||||
|
||||
// subscribe registers a match rule for the portal's SettingChanged signal and
|
||||
// spawns a goroutine that re-reads the scheme and fires onChange on each
|
||||
// relevant change.
|
||||
func (w *themeWatcher) subscribe() error {
|
||||
if err := w.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(portalObjectPath),
|
||||
dbus.WithMatchInterface(portalSettings),
|
||||
dbus.WithMatchMember("SettingChanged"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sigs := make(chan *dbus.Signal, 8)
|
||||
w.conn.Signal(sigs)
|
||||
go w.loop(sigs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loop consumes SettingChanged signals, filters to the colour-scheme key, and
|
||||
// repaints the icon when the dark/light preference actually flips.
|
||||
func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
|
||||
for sig := range sigs {
|
||||
if sig.Name != portalSettings+".SettingChanged" {
|
||||
continue
|
||||
}
|
||||
// Signal body: (namespace string, key string, value variant).
|
||||
if len(sig.Body) < 3 {
|
||||
continue
|
||||
}
|
||||
namespace, _ := sig.Body[0].(string)
|
||||
key, _ := sig.Body[1].(string)
|
||||
if namespace != appearanceNamespace || key != colorSchemeKey {
|
||||
continue
|
||||
}
|
||||
if _, ok := sig.Body[2].(dbus.Variant); !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-resolve via readDarkMode rather than the signal's value: under
|
||||
// KDE the panel colour comes from kdeglobals' Complementary group,
|
||||
// not the portal's color-scheme, so the signal value alone would be
|
||||
// wrong there. Off KDE this just re-reads the same color-scheme.
|
||||
w.update()
|
||||
}
|
||||
}
|
||||
|
||||
// update re-resolves the panel dark/light state and repaints the icon if it
|
||||
// flipped. Shared by the portal-signal loop and the KDE kdeglobals watcher.
|
||||
func (w *themeWatcher) update() {
|
||||
dark := w.readDarkMode()
|
||||
w.mu.Lock()
|
||||
changed := dark != w.darkMode
|
||||
w.darkMode = dark
|
||||
w.mu.Unlock()
|
||||
|
||||
if changed && w.onChange != nil {
|
||||
log.Infof("tray theme: panel dark mode changed to %v", dark)
|
||||
w.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
// watchKdeglobals watches the user kdeglobals file for changes and re-resolves
|
||||
// the panel theme on each write, so a KDE colour-scheme switch repaints the
|
||||
// icon live. KDE rewrites kdeglobals atomically (write-temp + rename), which
|
||||
// drops the inotify watch on the original inode, so we watch the parent
|
||||
// directory and filter to the kdeglobals name, re-arming implicitly.
|
||||
func (w *themeWatcher) watchKdeglobals() {
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
dir, name := filepath.Split(path)
|
||||
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err)
|
||||
return
|
||||
}
|
||||
if err := fw.Add(filepath.Clean(dir)); err != nil {
|
||||
log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err)
|
||||
_ = fw.Close()
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = fw.Close() }()
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-fw.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if filepath.Base(event.Name) != name {
|
||||
continue
|
||||
}
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
w.update()
|
||||
case err, ok := <-fw.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Debugf("tray theme: kdeglobals watch error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// variantToColorScheme unwraps the color-scheme variant (the portal nests it
|
||||
// one level: a variant holding a uint32) into the raw scheme value, returning
|
||||
// colorSchemeNoPreference for an unexpected payload.
|
||||
func variantToColorScheme(v dbus.Variant) uint32 {
|
||||
inner := v.Value()
|
||||
if nested, ok := inner.(dbus.Variant); ok {
|
||||
inner = nested.Value()
|
||||
}
|
||||
|
||||
switch n := inner.(type) {
|
||||
case uint32:
|
||||
return n
|
||||
case int32:
|
||||
return uint32(n)
|
||||
case uint8:
|
||||
return uint32(n)
|
||||
default:
|
||||
log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner)
|
||||
return colorSchemeNoPreference
|
||||
}
|
||||
}
|
||||
@@ -311,11 +311,12 @@ initialize_default_values() {
|
||||
NETBIRD_STUN_PORT=3478
|
||||
|
||||
# Docker images
|
||||
DASHBOARD_IMAGE="netbirdio/dashboard:latest"
|
||||
DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
|
||||
# Combined server replaces separate signal, relay, and management containers
|
||||
NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
|
||||
NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest"
|
||||
|
||||
NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
|
||||
NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"}
|
||||
TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"}
|
||||
CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"}
|
||||
# Reverse proxy configuration
|
||||
REVERSE_PROXY_TYPE="0"
|
||||
TRAEFIK_EXTERNAL_NETWORK=""
|
||||
@@ -656,7 +657,7 @@ render_docker_compose_traefik_builtin() {
|
||||
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
|
||||
crowdsec_service="
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.7
|
||||
image: $CROWDSEC_IMAGE
|
||||
container_name: netbird-crowdsec
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
@@ -687,7 +688,7 @@ render_docker_compose_traefik_builtin() {
|
||||
services:
|
||||
# Traefik reverse proxy (automatic TLS via Let's Encrypt)
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
image: $TRAEFIK_IMAGE
|
||||
container_name: netbird-traefik
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@@ -771,7 +772,7 @@ $traefik_dynamic_volume
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# gRPC router (needs h2c backend for HTTP/2 cleartext)
|
||||
- traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`))
|
||||
- traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`))
|
||||
- traefik.http.routers.netbird-grpc.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-grpc.tls=true
|
||||
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
@@ -514,7 +515,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
|
||||
for _, peer := range peers {
|
||||
|
||||
// Development version is always supported
|
||||
if peer.Meta.WtVersion == "development" {
|
||||
if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
|
||||
continue
|
||||
}
|
||||
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
|
||||
|
||||
@@ -932,7 +932,11 @@ func (s *Service) validateL4Target(target *Target) error {
|
||||
if target.TargetId == "" {
|
||||
return errors.New("target_id is required for L4 services")
|
||||
}
|
||||
if target.TargetType != TargetTypeCluster && target.Port == 0 {
|
||||
// Cluster targets resolve their upstream host:port from the target's
|
||||
// own Host/Port fields just like the other L4 types — buildPathMappings
|
||||
// emits net.JoinHostPort(target.Host, target.Port) for every L4
|
||||
// target, so allowing port=0 here would let ":0" reach the proxy.
|
||||
if target.Port == 0 {
|
||||
return errors.New("target port is required for L4 services")
|
||||
}
|
||||
switch target.TargetType {
|
||||
|
||||
@@ -1176,7 +1176,12 @@ func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
|
||||
assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
|
||||
}
|
||||
|
||||
func TestValidate_L4ClusterTarget(t *testing.T) {
|
||||
// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
|
||||
// target without an explicit port is rejected. buildPathMappings emits
|
||||
// net.JoinHostPort(target.Host, target.Port) for every L4 target — so
|
||||
// allowing port=0 would let the proxy ship ":0" upstreams. The port
|
||||
// requirement is the same as every other L4 target type.
|
||||
func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Mode = ModeTCP
|
||||
rp.ListenPort = 9000
|
||||
@@ -1186,7 +1191,12 @@ func TestValidate_L4ClusterTarget(t *testing.T) {
|
||||
Protocol: "tcp",
|
||||
Enabled: true,
|
||||
}}
|
||||
require.NoError(t, rp.Validate(), "L4 cluster target must validate without an explicit port")
|
||||
assert.ErrorContains(t, rp.Validate(), "port is required",
|
||||
"L4 cluster target must require an explicit port like other L4 target types")
|
||||
|
||||
rp.Targets[0].Port = 5432
|
||||
rp.Targets[0].Host = "db.lan"
|
||||
require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
|
||||
}
|
||||
|
||||
func TestService_Copy_RoundtripsPrivate(t *testing.T) {
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
s.errCh = make(chan error, 4)
|
||||
|
||||
if s.autoResolveDomains {
|
||||
s.resolveDomains(srvCtx)
|
||||
s.ResolveDomains(srvCtx)
|
||||
}
|
||||
|
||||
s.PeersManager()
|
||||
@@ -398,10 +398,10 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
|
||||
}()
|
||||
}
|
||||
|
||||
// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
|
||||
// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
|
||||
// Fresh installs use the default self-hosted domain, while existing installs reuse the
|
||||
// persisted account domain to keep addressing stable across config changes.
|
||||
func (s *BaseServer) resolveDomains(ctx context.Context) {
|
||||
func (s *BaseServer) ResolveDomains(ctx context.Context) {
|
||||
st := s.Store()
|
||||
|
||||
setDefault := func(logMsg string, args ...any) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
|
||||
@@ -40,7 +40,7 @@ func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, "vpn.mycompany.com", srv.dnsDomain)
|
||||
require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain)
|
||||
@@ -56,7 +56,7 @@ func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
|
||||
|
||||
@@ -102,7 +102,7 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
|
||||
|
||||
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
|
||||
t.Helper()
|
||||
token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, nil, time.Hour)
|
||||
token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return token
|
||||
}
|
||||
@@ -394,6 +394,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Cont
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
type testValidateSessionUsersManager struct {
|
||||
store store.Store
|
||||
}
|
||||
@@ -401,3 +405,24 @@ type testValidateSessionUsersManager struct {
|
||||
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
||||
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
}
|
||||
|
||||
func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
|
||||
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(user.AutoGroups) == 0 {
|
||||
return user, nil, nil
|
||||
}
|
||||
groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groups := make([]*types.Group, 0, len(user.AutoGroups))
|
||||
for _, id := range user.AutoGroups {
|
||||
if g, ok := groupsMap[id]; ok && g != nil {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
}
|
||||
return user, groups, nil
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const remoteJobsMinVer = "0.64.0"
|
||||
@@ -372,7 +373,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
|
||||
}
|
||||
|
||||
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
|
||||
if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
|
||||
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
|
||||
}
|
||||
|
||||
|
||||
@@ -4734,7 +4734,13 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength
|
||||
result := tx.
|
||||
Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue)
|
||||
if result.Error != nil {
|
||||
// no logging here
|
||||
// A tunnel-IP miss is an expected outcome (e.g. the proxy's
|
||||
// ValidateTunnelPeer probing an address that isn't in the
|
||||
// account roster); surface it as NotFound so callers can tell
|
||||
// it apart from a real store failure.
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String())
|
||||
}
|
||||
return nil, status.Errorf(status.Internal, "failed to get peer from store")
|
||||
}
|
||||
|
||||
@@ -5962,6 +5968,7 @@ func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column
|
||||
}
|
||||
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Model(&proxy.Proxy{}).
|
||||
Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+
|
||||
"COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true").
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
|
||||
if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
|
||||
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
|
||||
t.Skip("skip CI tests on darwin and windows")
|
||||
}
|
||||
|
||||
|
||||
@@ -491,6 +491,27 @@ func Test_GetAccount(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the
|
||||
// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the
|
||||
// account roster must surface as a NotFound error (not a generic
|
||||
// Internal) so callers can distinguish an expected miss from a real
|
||||
// store failure. A known IP still resolves.
|
||||
func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) {
|
||||
runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) {
|
||||
const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0"))
|
||||
require.NoError(t, err, "known tunnel IP must resolve")
|
||||
require.NotNil(t, peer)
|
||||
|
||||
_, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99"))
|
||||
require.Error(t, err, "unknown tunnel IP must error")
|
||||
parsedErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "error must be a status error")
|
||||
require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlStore_SavePeer(t *testing.T) {
|
||||
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1804,7 +1805,7 @@ func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *n
|
||||
|
||||
// peerSupportedFirewallFeatures checks if the peer version supports port ranges.
|
||||
func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
|
||||
if strings.Contains(peerVer, "dev") {
|
||||
if version.IsDevelopmentVersion(peerVer) {
|
||||
return supportedFeatures{true, true}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user