Compare commits

..

32 Commits

Author SHA1 Message Date
Zoltán Papp
ca6f6d88cb Merge branch 'main' into ui-refactor 2026-06-04 17:06:11 +02:00
Zoltán Papp
e298747203 [client/ui] Refresh profile list in tray + UI on CLI profile add/remove
The daemon emits no dedicated profile-changed RPC event, and a profile
add/remove doesn't move the connection status, so the UI's SubscribeStatus
path never fired for CLI-driven `netbird profile add|remove` (and the tray's
iconChanged guard would swallow it anyway). The tray menu and the React
profile list stayed stale until the next status-string transition.

AddProfile/RemoveProfile now publish a marked INFO/SYSTEM event over
SubscribeEvents (metadata kind=profile-list-changed, empty userMessage so it
stays silent). The UI's dispatchSystemEvent recognises the marker and
re-emits the existing EventProfileChanged, which the tray's loadProfiles and
React's ProfileContext.refresh already subscribe to — so both surfaces
refresh from a single signal that originates in the shared daemon handler
(covering both CLI and UI-initiated removals). No proto change.

Also drop a stray, build-breaking `app.Updater` line in main.go.
2026-06-04 16:03:22 +02:00
Theodor Midtlien
5993ec6e43 [client] Allow wireguard port to be zero in UI and show port in status command (#6158)
* Allow wireguard port to be set to 0 in UI

* Add wireguard port to cmd status

* Correct protoc version
2026-06-04 15:04:11 +02:00
Zoltán Papp
cba4a8a63b [client/ui] Fix login wedging until restart after a failed SSO attempt
startLogin held both guards (the module-level loginInFlight and the
caller's React-level loginGuard) across the post-failure errorDialog
await. The native Windows MessageBox disables its parent for its whole
lifetime while the main window's WindowClosing hook hides instead of
closing, so the dialog promise can outlive the click — and even a clean
dismissal kept the guards held until the promise settled. Until then
every later Connect click and tray trigger-login was silently dropped at
the guard check, so the only way back was a client restart.

Release both guards the instant the flow itself settles, before the
dialog: startLogin now takes an onSettled callback fired in its finally
(driveLogin releases loginGuard through it), and the errorDialog await
moved out of the try/finally so no guard is ever gated on the dialog.
2026-06-04 13:42:29 +02:00
Zoltán Papp
93e068f753 [client/ui] Honour DEV=true in windows:build:console for DevTools
windows:build:console fixed -tags production, which disables the
WebKit/WebView2 DevTools inspector — so there was no way to get a
console-attached Windows binary with the frontend JS console reachable.
Mirror build:native's DEV handling: DEV=true drops the production tag
while keeping the console subsystem (no -H windowsgui).
2026-06-04 13:21:41 +02:00
Zoltán Papp
94065a8058 [client/ui] Enable tray status row on Linux to avoid greyed-out indicator
The informational status row at the top of the tray menu was disabled on
Linux, which painted the connection-status indicator greyed-out. Enable
it on Linux so the row renders at full opacity; it has no OnClick handler
so clicking it remains a no-op.
2026-06-04 11:42:22 +02:00
Maycon Santos
eac6d501c3 [infrastructure] allow docker image overrides for getting started (#6335)
* [infrastructure] allow docker image overrides for getting started

Make dashboard and server image configurations overrideable via environment variables

* [infrastructure] update Traefik gRPC rule to include ProxyService PathPrefix

* make Traefik and CrowdSec images configurable via environment variables
2026-06-04 11:24:47 +02:00
Maycon Santos
deeae30612 [misc] Add Codecov integration and coverage reporting across workflows (#6333) 2026-06-03 19:08:45 +02:00
Bethuel Mmbaga
f3cdf163e1 [management] Export ResolveDomain (#6334) 2026-06-03 19:53:57 +03:00
Eduard Gert
d7263a6be9 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-03 17:33:20 +02:00
Eduard Gert
64199209cf add onboarding 2026-06-03 17:33:13 +02:00
Zoltán Papp
166c6118e2 [client] Fix Up failing with NeedsLogin after SSO login
After a successful WaitSSOLogin the daemon deliberately stays in
StatusNeedsLogin, and after a mid-session expiry (peer kicked out by the
management server) the engine tears down with clientRunning == false. In
both cases the caller's Up takes the fresh-start branch, which only
accepted StatusIdle and rejected NeedsLogin with
"up already in progress: current status NeedsLogin".

This forced a second Up to actually connect (CLI: re-run `netbird up`;
GUI: click Connect again). Treat NeedsLogin as a legitimate fresh-start
entry state and reset it to Idle before starting the engine, so the
first Up after login drives Connecting -> Connected directly.
2026-06-03 17:19:52 +02:00
Zoltan Papp
3e61ccb162 [client] Persist sync response via pluggable store (disk on iOS) (#6331)
* Persist sync response via pluggable store (disk on iOS)

The latest Management sync response (which carries the network map) was
kept in memory for debug bundle generation. On memory-constrained
platforms like iOS the network map can be large enough to matter.

Introduce a syncstore package with a Store interface and two backends:
a memory backend (the previous behavior) and a disk backend that
serializes the response to a file in the state directory. The backend
is selected per-platform at build time: disk on iOS, memory elsewhere.

The disk store clears any leftover file on construction so a fresh
store never reads stale data from an earlier run (e.g. another
profile's network map).

In the engine, drop the separate persistSyncResponse bool: the store is
only instantiated while persistence is enabled, and its presence is
what marks persistence as active. The store is also cleared on engine
close so the file does not linger on disk.

* syncstore: silence nilnil linter on "nothing stored" returns

Get returns (nil, nil) to signal that nothing is stored, which is part
of the Store contract and preserves the original behaviour. Annotate
both backends with //nolint:nilnil so golangci-lint does not flag it.

* syncstore: hold syncRespMux for the whole store Set/Get

Both handleSync and GetLatestSyncResponse snapshotted e.syncStore under
the read lock and then released it before calling Set/Get. That allowed
SetSyncResponsePersistence(false) or engine close to clear the store
mid-call. In particular a concurrent Clear()+nil followed by a late
Set could re-create the file that was just removed, defeating the
leak/lingering protection.

Hold syncRespMux for the duration of the store operation in both spots
so the store cannot be cleared while a Set/Get is in flight.

* syncstore: avoid StateDir "." when state path is empty

On mobile the state path may be empty (the engine tolerates a missing
state file). filepath.Dir("") returns ".", which would make a
disk-backed syncstore write into the working directory instead of
letting NewDiskStore fall back to os.TempDir().

Only set engineConfig.StateDir when path is non-empty.
2026-06-03 14:18:50 +02:00
Viktor Liu
a48c20d8d8 [client] Gate DNS forwarder on BlockInbound (#6257) 2026-06-03 11:33:29 +02:00
Riccardo Manfrin
2b57a7d43b [client, management, misc] expose VCS revision in dev build version output (#6263)
* Refactor to use a common checker for development version

* Adds commit sha to development version for cobra command only

Leave dashboard unaffected

* Adjust for "v0.31.1-dev" test case

which must be considered pre-release

* Drop synthetic "dev"/"0.50.0-dev" firewall feature-gate fixtures

These test cases encoded the loose strings.Contains(v, "dev")
semantics inherited from peerSupportedFirewallFeatures, but
NetbirdVersion() never produces those values — only the literal
"development" (and now "development-<sha>[-dirty]") ever flows
through the wire. The agent owns the semantics of an ephemeral
development build, so the tests should exercise the strings we
actually emit.

Replaced with development, development-<sha> and
development-<sha>-dirty cases that match the HasPrefix("development")
predicate introduced upstream.

* Remove unexistent tests on wire format

The sha / dirty flag are added only when the CLI asks the version.
Account versions is unaffacted and can only strictly match "development"

* Adds tests for IsDevelopmentVersion
2026-06-03 08:56:50 +02:00
Eduard Gert
1710868a09 add exit node switcher 2026-06-02 17:23:56 +02:00
Zoltán Papp
7798b7cf14 [client/ui] Disable WebKit compositing on Linux to avoid Intel/Mesa SIGSEGV
WebKitGTK's accelerated GL compositor crashes with a SIGSEGV inside
g_application_run on some Intel setups, hitting Mesa anv/i965 code paths
for DRM format modifiers that aren't implemented (FINISHME: YUV
colorspace / multi-planar formats). Disabling the DMA-BUF renderer alone
doesn't cover the GL compositor, so the crash survived that workaround.

Set WEBKIT_DISABLE_COMPOSITING_MODE=1 in init() (skipped if the user
already set it) to force CPU rendering, which is fine for a UI this
small and sidesteps the broken modifier path.
2026-06-02 15:10:45 +02:00
Zoltán Papp
179966b000 [client/ui] Detect KDE panel dark mode from kdeglobals Complementary colour
Split Linux panel-theme detection into two files and fix the KDE case
where the tray icon picked the wrong mono variant.

The freedesktop Settings portal's color-scheme reports the *global*
light/dark preference, but the KDE panel is painted from the
Complementary colour group, which can be dark even when the global
scheme is Light. The tray sits on the panel, so keying its black/white
mono icon off the portal value alone gave the wrong contrast on KDE.

Changes:
  - tray_theme_linux.go keeps the dark/light decision; on KDE it now
    reads the user's kdeglobals [Colors:Complementary] BackgroundNormal
    to determine the actual panel luminance, falling back to the portal
    color-scheme / GTK_THEME chain elsewhere.
  - tray_theme_watcher_linux.go (new) owns the live half: a private
    session-bus connection for the portal SettingChanged signal plus an
    fsnotify watch on kdeglobals, repainting the tray on a panel-theme
    flip.
  - tray_theme_linux_test.go (new) covers the kdeglobals Complementary
    parse against the KDE test-VM's real file layout.
2026-06-02 14:29:45 +02:00
Eduard Gert
48265a0143 add profile switch into settings, scroll to top after profile switch, add netbird icon to linux windows 2026-06-02 14:20:36 +02:00
Maycon Santos
fa1e241aea [management, client, proxy] Follow-up fixes for private reverse-proxy services (#6268)
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker

forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.

Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.

* fix(proxy): harden inbound listener resource + startup-ctx handling

Three correctness fixes on the per-account inbound path, with tests:

- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
  back an *io.PipeWriter backed by a pipe + scanner goroutine that the
  caller owns; the two writers per account (https + plain) were never
  closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
  is launched in a goroutine from AddPeer and was inheriting the
  caller's request-scoped ctx, so a cancelled request could abort the
  inbound bring-up or fail the management status notification. The
  tail is split into notifyClientReady so the contract is testable.

Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.

* feat(proxy): short-circuit peer-own-target loops with 421

When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.

Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.

The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.

* fix(management): private-service validation + tunnel-IP lookup semantics

- Require an explicit port for L4 cluster targets. validateL4Target
  exempted TargetTypeCluster from the port check, but buildPathMappings
  serializes every L4 target via net.JoinHostPort(host, port) — port=0
  shipped a ":0" upstream. Cluster targets use the same Host/Port
  fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
  every error to Internal. The proxy's ValidateTunnelPeer probes IPs
  that legitimately aren't in the roster; the miss is expected and now
  distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
  request doesn't keep the store busy.

Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.

* fix(client): include offlinePeers in PeerStateByIP lookup

ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:

- causes the DNS filter to keep returning records pointing at peers
  that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
  peer rather than letting the cached management RPC carry the
  authorisation decision.

Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.

* fix(rest): reject empty Delete path params in reverse-proxy clients

ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).

Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.

* chore(api,ci,docs,test): private-service schema, proto-check, fixups

Non-functional cleanups and contract/CI hardening around the
private-service work:

API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
  on both Service and ServiceRequest, mirroring
  validatePrivateRequirements. mode stays optional-but-constrained
  (empty defaults to http server-side), matching runtime.

CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
  -gen-go-grpc suffix) so grpc-generated files are in scope.

Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
  (initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
  comments and the proto doc.

Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
  signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
  unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.

* fix(proxy): background ctx for already-started AddPeer notification

The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.

Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.

* use the cmd context for roundtripper
2026-06-02 13:40:09 +02:00
Zoltán Papp
9a76507b14 [client/ui] Fix Linux package metadata and KDE Wayland icon association
Two Linux packaging issues, both surfaced by the netbird-ui deb/rpm
built from .goreleaser_ui.yaml.

1) License / vendor metadata
----------------------------
The nfpm entries for the UI package set only maintainer/description/
homepage, leaving the License and Vendor RPM/DEB tags empty. KDE
Discover (and GNOME Software) then render the package as
"Licenses: Unknown" / "Unknown author", with a scary license-warning
popup on install. The daemon's main .goreleaser.yaml already set
license (commit #5659) but never vendor, and the UI config was skipped
entirely.

Fix: add `license: BSD-3-Clause` + `vendor: NetBird` to both UI nfpm
entries (deb + rpm), and `vendor: NetBird` to the daemon's deb + rpm
entries for consistency. BSD-3-Clause is correct for client/ui — the
repo is BSD-3-Clause except management/, signal/, relay/, combined/
(AGPLv3), none of which the UI touches.

2) KDE Wayland window/taskbar icon
----------------------------------
On KDE Plasma 6 under Wayland the app launched with the generic Wails
icon in the window titlebar and the taskbar / Alt-Tab switcher, even
though /usr/share/pixmaps/netbird.png (the launcher icon, resolved from
the desktop entry's `Icon=netbird`) was correct.

Root cause is how a Wayland compositor decides a window's icon. Unlike
X11 there is no per-window _NET_WM_ICON the app can push at runtime —
GTK4 even removed gtk_window_set_icon, so the embedded assets/netbird.png
the binary carries is simply ignored. Instead the compositor matches the
window's Wayland **app_id** to an installed .desktop file and uses that
entry's `Icon=` key.

The app_id is not "netbird": Wails hardcodes it as `org.wails.<name>`
(pkg/application/linux_cgo.go: `fmt.Sprintf("org.wails.%s", name)`, name
= sanitized Options.Name), yielding **org.wails.netbird**. There is no
Wails option to override the prefix. Verified the live value on the
Fedora-40 / KDE 6.3 / Wayland test VM by dumping workspace.windowList()
via a KWin script:

    js: ZZZWIN org.wails.netbird ## NetBird

KDE needs two things to associate the running window with our desktop
entry and thus paint our icon:
  - the desktop entry's basename should equal the app_id, so the
    titlebar decoration (which looks the entry up by app_id ->
    <app_id>.desktop) finds it, and
  - a `StartupWMClass=<app_id>` line, which the taskbar / task switcher
    use to map the surface to the entry.

Fix (no Wails fork needed — app_id stays org.wails.netbird, which the
user never sees; only Name=NetBird and the icon are visible):
  - install the desktop file as `org.wails.netbird.desktop` instead of
    `netbird.desktop` (both deb and rpm contents in .goreleaser_ui.yaml)
  - add `StartupWMClass=org.wails.netbird` to
    client/ui/build/linux/netbird.desktop

`Icon=netbird` and the pixmaps/netbird.png payload are unchanged — they
were already correct. Confirmed on the test VM that both the titlebar
and taskbar/Alt-Tab now show the NetBird icon.
2026-06-02 12:52:24 +02:00
Eduard Gert
c3a0c1beeb update language switcher, remove flags 2026-06-02 12:36:29 +02:00
Eduard Gert
45095818bc update paddings and icons 2026-06-02 12:04:55 +02:00
braginini
1f74ee9c78 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-02 11:45:39 +02:00
braginini
562a538e91 fix(tray): round session-remaining to nearest hour/day 2026-06-02 11:45:29 +02:00
Eduard Gert
50b26a21fd Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor 2026-06-02 11:38:03 +02:00
Eduard Gert
fefd0da7bf Adjust peers and resources tabs 2026-06-02 11:37:56 +02:00
braginini
d1f3d88f0d Rephrase session expiration 2026-06-02 11:37:48 +02:00
Zoltán Papp
a71ef1bab0 [client/ui] Guard session expiry updates to log only on change 2026-06-02 11:09:20 +02:00
Eduard Gert
16743c4ce5 Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts:
#	client/ui/services/version.go
2026-06-02 09:37:57 +02:00
Eduard Gert
3c0a9c314b add gui version 2026-06-02 09:37:21 +02:00
Viktor Liu
e7c9182ff9 [client] Offer injected ICMPv6 echo replies to packet capture (#6321) 2026-06-01 19:38:00 +02:00
125 changed files with 3337 additions and 1253 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -4,7 +4,7 @@ import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, 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={

View File

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

View File

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

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

View File

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

View File

@@ -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",
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import {isWindows} from "@/lib/platform.ts";
import { isWindows } from "@/lib/platform.ts";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -143,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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-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>
);

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ export function SettingsAbout() {
return (
<div
className={
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-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"} />

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "/",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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