mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-30 18:49:08 +02:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # management/server/activity/codes.go # management/server/store/sql_store.go # management/server/store/sql_store_test.go # upload-server/server/server.go
This commit is contained in:
@@ -14,5 +14,15 @@ reviews:
|
||||
- "!**/*.ts"
|
||||
- "!**/*.js"
|
||||
- "!**/*.svg"
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: "No attribution trailers"
|
||||
mode: error
|
||||
instructions: >-
|
||||
Fail when the PR description or any commit message carries an
|
||||
attribution trailer or footer: Co-Authored-By, Claude-Session,
|
||||
Generated-By, or a "Generated with"/"Generated by" tool line.
|
||||
Contributors own their contributions (AGENTS.md); ask for the
|
||||
lines to be removed.
|
||||
chat:
|
||||
auto_reply: true
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Refuses commit messages that carry attribution trailers. Contributors own
|
||||
# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution
|
||||
# trailers"); a trailer spreads that ownership onto a tool or a bystander.
|
||||
|
||||
msg_file="$1"
|
||||
|
||||
# Trailer keys in any casing, with any bullet or emoji in front.
|
||||
trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):'
|
||||
# "Generated with/by" footers, including "Generated with <emoji> by".
|
||||
footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? '
|
||||
# A footer names a product, so a capitalized word must follow the phrase
|
||||
# itself. Prose such as "generated by the protobuf compiler" stays legal.
|
||||
tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]'
|
||||
|
||||
offending=$( {
|
||||
grep -Ein "$trailers" "$msg_file"
|
||||
grep -Ein "$footer" "$msg_file" | grep -E "$tool"
|
||||
} | sort -un )
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "commit-msg: attribution trailers are not accepted in this repository:" >&2
|
||||
printf '%s\n' "$offending" | sed 's/^/ /' >&2
|
||||
echo "Remove them and commit again (see AGENTS.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -46,3 +46,25 @@ updates:
|
||||
wireguard:
|
||||
patterns:
|
||||
- "golang.zx2c4.com/wireguard*"
|
||||
|
||||
# Base images of the source-build Dockerfiles, pinned by digest (Chainguard
|
||||
# publishes only :latest for free). Dockerfile.release files feed goreleaser
|
||||
# and keep the published images as they are, so their bases are left alone.
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "/upload-server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
base-images:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "gcr.io/distroless/base"
|
||||
# Go minor and major versions move with the rest of the repository;
|
||||
# patch releases and new digests of the pinned tag still come through.
|
||||
- dependency-name: "golang"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }}
|
||||
|
||||
@@ -46,15 +46,17 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# Exclude the client/ui package itself: its main.go uses //go:embed
|
||||
# all:frontend/dist, which fails to compile until the frontend has been
|
||||
# built, and its release pipeline runs `pnpm build` before goreleaser.
|
||||
# The pattern is anchored so the subpackages (services, preferences,
|
||||
# i18n, authsession) still run: they hold Go-side unit tests and need no
|
||||
# frontend bundle.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -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 -e /client/testutil/privileged)
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -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$' -e /client/testutil/privileged)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||
|
||||
@@ -160,9 +160,10 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# which fails to compile until the frontend has been built, and its
|
||||
# release pipeline runs `pnpm build` before goreleaser. The subpackages
|
||||
# go with it because this runner's gtk4 is older than the wails runtime
|
||||
# needs; the Client UI / Unit job below covers them instead.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# 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
|
||||
@@ -177,6 +178,35 @@ jobs:
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
test_client_ui:
|
||||
name: "Client UI / Unit"
|
||||
# Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails
|
||||
# runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10,
|
||||
# and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot
|
||||
# move this out from under us.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev
|
||||
|
||||
- name: Test
|
||||
# client/ui itself stays out: its main.go embeds all:frontend/dist,
|
||||
# which only exists after `pnpm build`. The subpackages carry the
|
||||
# Go-side unit tests, including the window manager re-entrancy
|
||||
# regression test, and need no frontend bundle.
|
||||
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -211,6 +241,9 @@ jobs:
|
||||
${{ runner.os }}-gotest-cache-
|
||||
|
||||
- name: Run tests in container
|
||||
# Unlike the native job above, this one drops all of client/ui including
|
||||
# the subpackages: the alpine container has no gtk4/webkitgtk, so the
|
||||
# Wails application package they import would fail to link.
|
||||
env:
|
||||
HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }}
|
||||
HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }}
|
||||
@@ -481,14 +514,32 @@ jobs:
|
||||
if: matrix.store == 'mysql'
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
|
||||
# The -json stream goes through tools/gotestsummary so the log shows one
|
||||
# line per test, the output of failed tests, the head of a timeout panic
|
||||
# with the still-running tests, and the slowest tests per package.
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
go test -json -tags=devcert -coverprofile=coverage.txt \
|
||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||
-timeout 20m ./management/... ./shared/management/...
|
||||
-timeout 20m ./management/... ./shared/management/... \
|
||||
| tee management-test-events.jsonl \
|
||||
| go run ./tools/gotestsummary
|
||||
|
||||
# The summary trims long outputs; the raw stream keeps every line for
|
||||
# the failures that need it. A green run has no use for it.
|
||||
- name: Upload raw test events
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: management-unit-test-events-${{ matrix.store }}
|
||||
path: management-test-events.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
@@ -738,12 +789,27 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
# Same summary as the unit job: a timeout here names the tests still
|
||||
# running instead of ending in a goroutine dump.
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
|
||||
mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \
|
||||
| tee management-integration-test-events.jsonl \
|
||||
| go run ./tools/gotestsummary
|
||||
|
||||
- name: Upload raw test events
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: management-integration-test-events-${{ matrix.store }}
|
||||
path: management-integration-test-events.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
|
||||
@@ -66,15 +66,17 @@ jobs:
|
||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||
- name: Generate test script
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# Exclude the client/ui package itself: its main.go uses //go:embed
|
||||
# all:frontend/dist, which fails to compile until the frontend has been
|
||||
# built, and its release pipeline runs `pnpm build` before goreleaser.
|
||||
# The pattern is anchored so the subpackages (services, preferences,
|
||||
# i18n, authsession) still run: they hold Go-side unit tests and need no
|
||||
# frontend bundle.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the Where-Object pipeline then drops the broken package by
|
||||
# path. Without -e, go list aborts with empty stdout.
|
||||
run: |
|
||||
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
||||
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
|
||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||
|
||||
@@ -80,3 +80,49 @@ jobs:
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=20m
|
||||
|
||||
# Separate job rather than extra rows in the matrix above: those rows pick a
|
||||
# GOOS by picking a runner OS, while android/ios are cross-compiled from
|
||||
# ubuntu — an `include` entry with os: ubuntu-latest would merge into the
|
||||
# Linux row instead of adding one. The package path is restricted because a
|
||||
# whole-repo run under GOOS=android pulls *_linux.go files into packages that
|
||||
# have no android counterpart.
|
||||
golangci-mobile:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: android
|
||||
goarch: arm64
|
||||
packages: ./client/android/...
|
||||
display_name: Android
|
||||
- goos: ios
|
||||
goarch: arm64
|
||||
packages: ./client/ios/...
|
||||
display_name: iOS
|
||||
name: ${{ matrix.display_name }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
with:
|
||||
version: latest
|
||||
install-mode: binary
|
||||
skip-cache: true
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=20m ${{ matrix.packages }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android_build:
|
||||
name: "Android / Build"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [arm64, arm, amd64, "386"]
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: android
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Build Android bridge
|
||||
run: go build ./client/android/...
|
||||
- name: Vet Android bridge
|
||||
if: matrix.goarch == 'arm64'
|
||||
run: go vet ./client/android/...
|
||||
|
||||
ios_build:
|
||||
name: "iOS / Build"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch: [arm64, amd64]
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ios
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
# No `go vet` counterpart: every ios target requires external (cgo)
|
||||
# linking, which needs an Xcode toolchain the runner does not have.
|
||||
- name: Build iOS SDK
|
||||
run: go build ./client/ios/...
|
||||
+154
-11
@@ -186,6 +186,14 @@ jobs:
|
||||
run: bash shared/management/http/api/generate.sh
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Generate RPM changelog from git tags
|
||||
# nfpm embeds changelog.yml into the RPM; Red Hat software certification
|
||||
# requires a changelog. Generated, not committed (see .gitignore).
|
||||
# chglog is a go.mod tool directive, so go.sum pins it and its deps.
|
||||
run: bash release_files/rpm-changelog.sh
|
||||
- name: Fill the RPM ISA provide version
|
||||
# nfpm cannot emit rpmbuild's ISA provide and GoReleaser cannot template it.
|
||||
run: bash release_files/rpm-provides.sh
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -225,14 +233,18 @@ jobs:
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --clean ${{ env.flags }}
|
||||
args: release --config .goreleaser.generated.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
# One per nfpm id: GoReleaser looks the passphrase up as NFPM_<ID>_PASSPHRASE.
|
||||
NFPM_NETBIRD_RPM_AMD64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_ARM64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_ARM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
NFPM_NETBIRD_RPM_386_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
|
||||
- name: Verify RPM signatures
|
||||
@@ -287,10 +299,15 @@ jobs:
|
||||
image_refs=()
|
||||
|
||||
tag_and_push() {
|
||||
local src="$1" img_name tag dst
|
||||
local src="$1" img_name tag dst variant=""
|
||||
img_name="${src%%:*}"
|
||||
# Client variants share a repository, so keep their tag suffixes.
|
||||
case "$src" in
|
||||
*-rootless-ubi-amd64) variant="-rootless-ubi" ;;
|
||||
*-rootless-amd64) variant="-rootless" ;;
|
||||
esac
|
||||
for tag in $(resolve_tags); do
|
||||
dst="${img_name}:${tag}"
|
||||
dst="${img_name}:${tag}${variant}"
|
||||
echo "Tagging ${src} -> ${dst}"
|
||||
docker tag "$src" "$dst"
|
||||
docker push "$dst"
|
||||
@@ -353,6 +370,132 @@ jobs:
|
||||
path: dist/netbird_darwin**
|
||||
retention-days: 7
|
||||
|
||||
# Certify and publish the rootless UBI client image in the Red Hat Ecosystem
|
||||
# Catalog. Stable tags only: goreleaser pushes <version>-rootless-ubi to
|
||||
# ghcr.io in the release job above, and preflight submits every architecture
|
||||
# of that manifest list to Pyxis. Auto-publish on the component makes the new
|
||||
# version public once certification passes.
|
||||
redhat_certification:
|
||||
name: "Red Hat / Certify rootless UBI image"
|
||||
needs: release
|
||||
if: |
|
||||
github.repository == 'netbirdio/netbird' &&
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
!contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PREFLIGHT_VERSION: "1.21.0"
|
||||
# sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release.
|
||||
# Red Hat publishes no checksum file, so the value is pinned here.
|
||||
PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449"
|
||||
IMAGE_REPOSITORY: "ghcr.io/netbirdio/netbird"
|
||||
# Component "NetBird Client Container Image (rootless)" in Partner Connect.
|
||||
# Override with the REDHAT_CERT_COMPONENT_ID repository variable if it changes.
|
||||
DEFAULT_COMPONENT_ID: "6aa3ca4b4676aefdf07aaa97"
|
||||
steps:
|
||||
- name: Resolve image reference
|
||||
id: image
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${INPUT_VERSION#v}"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${IMAGE_REPOSITORY}:${version}-rootless-ubi" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify the multi-arch image is on ghcr.io
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json
|
||||
for arch in amd64 arm64; do
|
||||
if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then
|
||||
echo "::error::${IMAGE_REF} has no ${arch} manifest"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Manifest list for ${IMAGE_REF}:"
|
||||
jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json
|
||||
|
||||
- name: Install preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \
|
||||
"https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64"
|
||||
echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c -
|
||||
chmod +x preflight
|
||||
./preflight --version
|
||||
|
||||
- name: Run preflight checks and submit to Red Hat
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.ref }}
|
||||
PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }}
|
||||
PFLT_CERTIFICATION_COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }}
|
||||
PFLT_ARTIFACTS: artifacts
|
||||
PFLT_LOGFILE: artifacts/preflight.log
|
||||
PFLT_LOGLEVEL: info
|
||||
PFLT_JUNIT: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No --platform: preflight walks the manifest list and submits every
|
||||
# architecture in one run, grouped under one manifest-list digest.
|
||||
./preflight check container "$IMAGE_REF" --submit
|
||||
|
||||
- name: Fail if any check did not pass
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
results=(artifacts/results.json artifacts/*/results.json)
|
||||
if [[ ${#results[@]} -eq 0 ]]; then
|
||||
echo "::error::preflight produced no results.json"
|
||||
exit 1
|
||||
fi
|
||||
status=0
|
||||
for f in "${results[@]}"; do
|
||||
arch="$(basename "$(dirname "$f")")"
|
||||
passed="$(jq -r '.passed' "$f")"
|
||||
failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")"
|
||||
echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}"
|
||||
[[ "$passed" == "true" ]] || status=1
|
||||
done
|
||||
exit $status
|
||||
|
||||
- name: Upload preflight artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: redhat-preflight-${{ steps.image.outputs.version }}
|
||||
path: artifacts/
|
||||
retention-days: 30
|
||||
|
||||
- name: Wait for Pyxis to mark both architectures certified
|
||||
env:
|
||||
VERSION: ${{ steps.image.outputs.version }}
|
||||
PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }}
|
||||
COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${VERSION}-rootless-ubi"
|
||||
url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?page_size=100"
|
||||
for attempt in $(seq 1 20); do
|
||||
certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \
|
||||
| jq -r --arg t "$tag" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')"
|
||||
echo "attempt ${attempt}: certified architectures for ${tag}: ${certified:-none}"
|
||||
if [[ "$certified" == "amd64,arm64" ]]; then
|
||||
echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own."
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "::warning::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images"
|
||||
|
||||
release_ui:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -407,12 +550,12 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -544,12 +687,12 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
@@ -641,11 +784,11 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11
|
||||
- name: Install wails3 CLI
|
||||
@@ -764,7 +907,7 @@ jobs:
|
||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||
|
||||
- name: Set up Go for wails3 CLI
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
|
||||
@@ -35,3 +35,10 @@ vendor/
|
||||
/netbird
|
||||
client/netbird-electron/
|
||||
management/server/types/testdata/
|
||||
|
||||
# generated by chglog in the release workflow, embedded into the RPM
|
||||
changelog.yml
|
||||
|
||||
# generated by rpm-provides.sh, the config GoReleaser actually runs
|
||||
.goreleaser.generated.yaml
|
||||
.chglog.yml
|
||||
|
||||
+121
-6
@@ -40,6 +40,32 @@ builds:
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
# Single-arch builds: nfpm provides is not templated, so the RPM splits per arch.
|
||||
- &netbird_rpm_build
|
||||
id: netbird-rpm-amd64
|
||||
dir: client
|
||||
binary: netbird
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux]
|
||||
goarch: [amd64]
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-arm64
|
||||
goarch: [arm64]
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-arm
|
||||
goarch: [arm]
|
||||
|
||||
- <<: *netbird_rpm_build
|
||||
id: netbird-rpm-386
|
||||
goarch: [386]
|
||||
|
||||
- id: netbird-static
|
||||
dir: client
|
||||
binary: netbird
|
||||
@@ -223,23 +249,72 @@ nfpms:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
- &netbird_rpm
|
||||
maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
id: netbird_rpm_amd64
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
- netbird
|
||||
ids:
|
||||
- netbird-rpm-amd64
|
||||
formats:
|
||||
- rpm
|
||||
# Red Hat certification (RPM Version Handling) requires rpmbuild's ISA
|
||||
# provide, which nfpm does not emit. The version is filled in by the release job.
|
||||
provides:
|
||||
- "netbird(x86-64) = @RPM_EVR@"
|
||||
# The client verifies TLS to management and signal against the system trust
|
||||
# store. Red Hat software certification (RPM Dependency Tracking) also
|
||||
# rejects packages that declare no dependencies at all.
|
||||
dependencies:
|
||||
- ca-certificates
|
||||
# Generated in CI by chglog from git tags; Red Hat certification requires an
|
||||
# RPM changelog (RPM Version Handling subtest).
|
||||
changelog: changelog.yml
|
||||
# License, documentation and a config file so the RPM Provenance subtest sees
|
||||
# %license, %doc and %config entries instead of a bare binary.
|
||||
contents:
|
||||
- src: LICENSE
|
||||
dst: /usr/share/licenses/netbird/LICENSE
|
||||
type: license
|
||||
- src: README.md
|
||||
dst: /usr/share/doc/netbird/README.md
|
||||
type: doc
|
||||
- src: release_files/netbird.sysconfig
|
||||
dst: /etc/sysconfig/netbird
|
||||
type: config|noreplace
|
||||
scripts:
|
||||
postinstall: "release_files/post_install.sh"
|
||||
preremove: "release_files/pre_remove.sh"
|
||||
rpm:
|
||||
summary: NetBird client
|
||||
group: Applications/Internet
|
||||
packager: NetBird <dev@netbird.io>
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_arm64
|
||||
ids:
|
||||
- netbird-rpm-arm64
|
||||
provides:
|
||||
- "netbird(aarch-64) = @RPM_EVR@"
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_arm
|
||||
ids:
|
||||
- netbird-rpm-arm
|
||||
provides:
|
||||
- "netbird(armv6hl-32) = @RPM_EVR@"
|
||||
|
||||
- <<: *netbird_rpm
|
||||
id: netbird_rpm_386
|
||||
ids:
|
||||
- netbird-rpm-386
|
||||
provides:
|
||||
- "netbird(x86-32) = @RPM_EVR@"
|
||||
dockers_v2:
|
||||
- id: netbird
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
@@ -289,6 +364,43 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-rootless-ubi
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird
|
||||
images:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "{{ .Version }}-rootless-ubi"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless.ubi
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
build_args:
|
||||
VERSION: "{{ .Version }}"
|
||||
RELEASE: "{{ .Timestamp }}"
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64'
|
||||
env:
|
||||
- GOOS=linux
|
||||
- CGO_ENABLED=0
|
||||
labels:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: relay
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
@@ -365,7 +477,7 @@ dockers_v2:
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: upload-server/Dockerfile
|
||||
dockerfile: upload-server/Dockerfile.release
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
@@ -453,7 +565,10 @@ uploads:
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_rpm
|
||||
- netbird_rpm_amd64
|
||||
- netbird_rpm_arm64
|
||||
- netbird_rpm_arm
|
||||
- netbird_rpm_386
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
|
||||
@@ -77,7 +77,7 @@ make lint # golangci-lint on files changed vs origin/main (also the p
|
||||
make lint-all # full-repository lint, matches CI
|
||||
make test-unit # host-safe unit tests, -tags devcert, no sudo
|
||||
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
|
||||
make setup-hooks # wire make lint into .githooks/pre-push
|
||||
make setup-hooks # wire .githooks: pre-push runs make lint, commit-msg refuses attribution trailers
|
||||
|
||||
# Narrow runs
|
||||
go test ./client/internal/dns/...
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
|
||||
The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so
|
||||
every session loads it in full rather than following a pointer.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
@@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT)
|
||||
# Setup git hooks for all developers
|
||||
setup-hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@chmod +x .githooks/pre-push
|
||||
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
|
||||
@chmod +x .githooks/pre-push .githooks/commit-msg
|
||||
@echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers"
|
||||
|
||||
# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
|
||||
# Runs as a normal user with no sudo and leaves host networking untouched.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||
ARG VERSION=dev
|
||||
ARG RELEASE=1
|
||||
|
||||
LABEL name="netbird-rootless" \
|
||||
maintainer="NetBird <dev@netbird.io>" \
|
||||
vendor="NetBird GmbH" \
|
||||
version="${VERSION}" \
|
||||
release="${RELEASE}" \
|
||||
summary="NetBird Rootless Client" \
|
||||
description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities."
|
||||
|
||||
RUN microdnf install -y bash ca-certificates && microdnf clean all
|
||||
|
||||
COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||
COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird
|
||||
COPY licenses/ /licenses/
|
||||
# Only application storage is group-writable for arbitrary non-root UIDs.
|
||||
# Runtime-created credentials keep the client's restrictive file modes.
|
||||
RUN mkdir -p /var/lib/netbird && \
|
||||
chown 1000:0 /var/lib/netbird && \
|
||||
chmod 0770 /var/lib/netbird && \
|
||||
chmod -R a+rX /licenses
|
||||
|
||||
WORKDIR /var/lib/netbird
|
||||
USER 1000:0
|
||||
|
||||
ENV \
|
||||
HOME="/var/lib/netbird" \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_USE_NETSTACK_MODE="true" \
|
||||
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
|
||||
NB_CONFIG="/var/lib/netbird/config.json" \
|
||||
NB_STATE_DIR="/var/lib/netbird" \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"]
|
||||
@@ -31,6 +31,8 @@ const (
|
||||
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
||||
// a string because gomobile flattens errors to their message, so a sentinel
|
||||
// value would not survive the binding.
|
||||
//
|
||||
//nolint:gosec // G101 false positive: a sentinel marker, not a credential
|
||||
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
||||
|
||||
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -25,6 +26,30 @@ var serviceCmd = &cobra.Command{
|
||||
|
||||
const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
|
||||
|
||||
// forbiddenServiceEnvVars are the environment variables the service is never
|
||||
// registered with, keyed in upper case since these are Windows names. Each one
|
||||
// decides where the daemon resolves something it then uses with the privileges
|
||||
// of the account it runs under — LocalSystem on Windows, root elsewhere: the
|
||||
// executables it runs (PATH, PATHEXT, COMSPEC, SystemRoot, windir) or the
|
||||
// directory it writes temporary files in (TEMP, TMP). The daemon needs none of
|
||||
// them, and the utilities it shells out to are resolved by absolute path.
|
||||
var forbiddenServiceEnvVars = map[string]struct{}{
|
||||
"PATH": {},
|
||||
"PATHEXT": {},
|
||||
"SYSTEMROOT": {},
|
||||
"WINDIR": {},
|
||||
"COMSPEC": {},
|
||||
"TEMP": {},
|
||||
"TMP": {},
|
||||
}
|
||||
|
||||
// forbiddenServiceEnvPrefixes are the dynamic-loader families, refused whole
|
||||
// rather than by name: LD_PRELOAD, DYLD_INSERT_LIBRARIES and their siblings all
|
||||
// reach the loader of the process, the set differs per platform and libc, and
|
||||
// new members arrive with new OS releases. Listing them one by one is a list
|
||||
// that is wrong the moment it is written.
|
||||
var forbiddenServiceEnvPrefixes = []string{"LD_", "DYLD_"}
|
||||
|
||||
var (
|
||||
serviceName string
|
||||
serviceEnvVars []string
|
||||
@@ -127,8 +152,33 @@ func parseServiceEnvVars(envVars []string) (map[string]string, error) {
|
||||
return nil, fmt.Errorf("empty environment variable key in: %s", env)
|
||||
}
|
||||
|
||||
if isForbiddenServiceEnvVar(key) {
|
||||
return nil, fmt.Errorf("environment variable %s cannot be set on the service: it decides where the service resolves the executables, libraries or temporary files it uses", key)
|
||||
}
|
||||
|
||||
envMap[key] = value
|
||||
}
|
||||
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
// isForbiddenServiceEnvVar reports whether name is one the service must not be
|
||||
// registered with.
|
||||
//
|
||||
// The names are matched case-insensitively only on Windows, where they are the
|
||||
// same variable however they are spelled. Elsewhere the environment is
|
||||
// case-sensitive, so Path and PATH are two different variables and only the
|
||||
// exact spelling is the one the loader reads.
|
||||
func isForbiddenServiceEnvVar(name string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
name = strings.ToUpper(name)
|
||||
}
|
||||
|
||||
if _, forbidden := forbiddenServiceEnvVars[name]; forbidden {
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(forbiddenServiceEnvPrefixes, func(prefix string) bool {
|
||||
return strings.HasPrefix(name, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -43,10 +44,33 @@ func serviceParamsPath() string {
|
||||
|
||||
// loadServiceParams reads saved service parameters from disk.
|
||||
// Returns nil with no error if the file does not exist.
|
||||
//
|
||||
// The file is read by an elevated install and decides the arguments and the
|
||||
// environment of the service it then registers, so it is used only when its
|
||||
// ownership and permissions are the ones saveServiceParams leaves behind. That
|
||||
// restricted ACL is applied when the file is written, which is not necessarily
|
||||
// before it is first read, so this is checked rather than assumed. A file that
|
||||
// fails the check is treated as absent, and the install proceeds with its
|
||||
// defaults.
|
||||
func loadServiceParams() (*serviceParams, error) {
|
||||
path := serviceParamsPath()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
// Resolve links first so the checks apply to the file that is actually read.
|
||||
// Since the check covers every directory above it as well, nobody who fails
|
||||
// it can swap the file between here and the read below.
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("resolve service params %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := elevate.CheckOnlyOwnerWritable(resolved); err != nil {
|
||||
return nil, fmt.Errorf("refusing to read service params from %s: %w", resolved, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil //nolint:nilnil
|
||||
@@ -182,10 +206,16 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
// If --service-env was explicitly set to empty, all saved env vars are cleared.
|
||||
// If --service-env was not set, saved env vars are used entirely.
|
||||
func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
// A forbidden name explicitly passed on the command line is an error the
|
||||
// operator is told about, but one restored from a file written by an older
|
||||
// version is dropped: an install that refuses to run would leave the host
|
||||
// without a daemon over a variable nobody is asking for any more.
|
||||
saved := dropForbiddenServiceEnvVars(cmd, params.ServiceEnvVars)
|
||||
|
||||
if !cmd.Flags().Changed("service-env") {
|
||||
if len(params.ServiceEnvVars) > 0 {
|
||||
if len(saved) > 0 {
|
||||
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||
serviceEnvVars = envMapToSlice(saved)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -204,13 +234,13 @@ func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(params.ServiceEnvVars) == 0 {
|
||||
if len(saved) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Merge saved values underneath explicit ones.
|
||||
merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit))
|
||||
maps.Copy(merged, params.ServiceEnvVars)
|
||||
merged := make(map[string]string, len(saved)+len(explicit))
|
||||
maps.Copy(merged, saved)
|
||||
maps.Copy(merged, explicit) // explicit wins on conflict
|
||||
serviceEnvVars = envMapToSlice(merged)
|
||||
}
|
||||
@@ -233,6 +263,20 @@ var resetParamsCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// dropForbiddenServiceEnvVars returns the saved entries that may still be
|
||||
// registered on the service, reporting every one it leaves behind.
|
||||
func dropForbiddenServiceEnvVars(cmd *cobra.Command, saved map[string]string) map[string]string {
|
||||
kept := make(map[string]string, len(saved))
|
||||
for key, value := range saved {
|
||||
if isForbiddenServiceEnvVar(key) {
|
||||
cmd.PrintErrf("Warning: ignoring saved service environment variable %s: it decides where the service resolves the executables, libraries or temporary files it uses\n", key)
|
||||
continue
|
||||
}
|
||||
kept[key] = value
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// envMapToSlice converts a map of env vars to a KEY=VALUE slice.
|
||||
func envMapToSlice(m map[string]string) []string {
|
||||
s := make([]string, 0, len(m))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -353,6 +354,59 @@ func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
||||
}
|
||||
|
||||
func TestParseServiceEnvVars_RejectsForbiddenNames(t *testing.T) {
|
||||
for _, env := range []string{"PATH=C:\\somewhere", "LD_PRELOAD=/tmp/lib.so", "DYLD_FALLBACK_LIBRARY_PATH=/tmp"} {
|
||||
_, err := parseServiceEnvVars([]string{"KEEP=me", env})
|
||||
require.Errorf(t, err, "%s selects what the service resolves and must be refused", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsForbiddenServiceEnvVar(t *testing.T) {
|
||||
// The loader families are matched by prefix, so a name nobody has heard of
|
||||
// yet is refused too.
|
||||
for _, name := range []string{
|
||||
"PATH", "PATHEXT", "COMSPEC", "SYSTEMROOT", "WINDIR", "TEMP", "TMP",
|
||||
"LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_FALLBACK_FRAMEWORK_PATH",
|
||||
} {
|
||||
assert.Truef(t, isForbiddenServiceEnvVar(name), "%s must be refused", name)
|
||||
}
|
||||
|
||||
// The prefix must not swallow names that merely start with the same letters.
|
||||
for _, name := range []string{"NB_LOG_LEVEL", "NB_WG_DEBUG", "HTTPS_PROXY", "LDAP_URL", "DYLDX"} {
|
||||
assert.Falsef(t, isForbiddenServiceEnvVar(name), "%s has no reason to be refused", name)
|
||||
}
|
||||
|
||||
// On Windows a variable is the same one however it is spelled; elsewhere
|
||||
// Path and PATH are two variables and only the exact one is read.
|
||||
if runtime.GOOS == "windows" {
|
||||
assert.True(t, isForbiddenServiceEnvVar("Path"))
|
||||
assert.True(t, isForbiddenServiceEnvVar("ld_preload"))
|
||||
} else {
|
||||
assert.False(t, isForbiddenServiceEnvVar("Path"))
|
||||
assert.False(t, isForbiddenServiceEnvVar("ld_preload"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_DropsForbiddenSavedNames(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
serviceEnvVars = nil
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
|
||||
saved := &serviceParams{
|
||||
ServiceEnvVars: map[string]string{"PATH": "C:\\attacker", "NB_LOG_FORMAT": "json"},
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, saved)
|
||||
|
||||
result, err := parseServiceEnvVars(serviceEnvVars)
|
||||
require.NoError(t, err, "a saved PATH must be dropped rather than fail the install")
|
||||
assert.Equal(t, map[string]string{"NB_LOG_FORMAT": "json"}, result)
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
)
|
||||
|
||||
// The Windows equivalent of this is the ACL check in
|
||||
// elevate.CheckOnlyOwnerWritable, covered by that package's own tests; here the
|
||||
// point is that loadServiceParams asks the question at all.
|
||||
func TestLoadServiceParams_RefusesWorldWritableFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = tmpDir
|
||||
|
||||
path := filepath.Join(tmpDir, serviceParamsFile)
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"log_level":"debug"}`), 0o666))
|
||||
// WriteFile is subject to the umask, so set the bits that matter explicitly.
|
||||
require.NoError(t, os.Chmod(path, 0o666))
|
||||
|
||||
params, err := loadServiceParams()
|
||||
require.Error(t, err, "a service.json anyone can rewrite must not be trusted")
|
||||
assert.Nil(t, params)
|
||||
|
||||
require.NoError(t, os.Chmod(path, 0o600))
|
||||
params, err = loadServiceParams()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, params)
|
||||
assert.Equal(t, "debug", params.LogLevel)
|
||||
}
|
||||
|
||||
func TestLoadServiceParams_RefusesWorldWritableDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
stateDir := filepath.Join(tmpDir, "state")
|
||||
require.NoError(t, os.Mkdir(stateDir, 0o777))
|
||||
require.NoError(t, os.Chmod(stateDir, 0o777))
|
||||
|
||||
original := configs.StateDir
|
||||
t.Cleanup(func() { configs.StateDir = original })
|
||||
configs.StateDir = stateDir
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(stateDir, serviceParamsFile), []byte(`{}`), 0o600))
|
||||
|
||||
params, err := loadServiceParams()
|
||||
require.Error(t, err, "a service.json in a directory anyone can replace entries in must not be trusted")
|
||||
assert.Nil(t, params)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
output_name=$(basename "$1")
|
||||
if [ -z "$output_name" ] || [ "$output_name" = "." ] ||
|
||||
[ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then
|
||||
printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd)
|
||||
output="$output_parent/$output_name"
|
||||
shift
|
||||
modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX")
|
||||
sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX")
|
||||
trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM
|
||||
|
||||
if [ -e "$output" ] || [ -L "$output" ]; then
|
||||
printf 'output directory already exists: %s\n' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir "$output"
|
||||
mkdir "$output/third_party"
|
||||
|
||||
cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt"
|
||||
|
||||
cd "$repo_root"
|
||||
for arch in "$@"; do
|
||||
GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \
|
||||
go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules"
|
||||
done
|
||||
LC_ALL=C sort -u "$modules" >"$sorted_modules"
|
||||
|
||||
goroot=$(go env GOROOT)
|
||||
for term in LICENSE PATENTS; do
|
||||
if [ ! -f "$goroot/$term" ]; then
|
||||
printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$goroot/$term" "$output/Go-$term"
|
||||
done
|
||||
|
||||
while IFS=' ' read -r module version module_dir; do
|
||||
[ -n "$module" ] || continue
|
||||
[ "$module" = "github.com/netbirdio/netbird" ] && continue
|
||||
|
||||
if [ -z "$version" ] || [ ! -d "$module_dir" ]; then
|
||||
printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
destination="$output/third_party/$module/$version"
|
||||
mkdir -p "$destination"
|
||||
printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE"
|
||||
|
||||
found=false
|
||||
for term in \
|
||||
"$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \
|
||||
"$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \
|
||||
"$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \
|
||||
"$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \
|
||||
"$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do
|
||||
[ -f "$term" ] || continue
|
||||
cp "$term" "$destination/"
|
||||
found=true
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
done <"$sorted_modules"
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/internal/wincmd"
|
||||
)
|
||||
|
||||
type action string
|
||||
@@ -91,7 +92,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err
|
||||
if action == addRule {
|
||||
args = append(args, extraArgs...)
|
||||
}
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Run()
|
||||
@@ -100,7 +101,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err
|
||||
func isWindowsFirewallReachable() bool {
|
||||
args := []string{"advfirewall", "show", "allprofiles", "state"}
|
||||
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
@@ -117,23 +118,10 @@ func isWindowsFirewallReachable() bool {
|
||||
func isFirewallRuleActive(ruleName string) bool {
|
||||
args := []string{"advfirewall", "firewall", "show", "rule", "name=" + ruleName}
|
||||
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
|
||||
cmd := exec.Command(netshCmd, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
_, err := cmd.Output()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetSystem32Command checks if a command can be found in the system path and returns it. In case it can't find it
|
||||
// in the path it will return the full path of a command assuming C:\windows\system32 as the base path.
|
||||
func GetSystem32Command(command string) string {
|
||||
_, err := exec.LookPath(command)
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
|
||||
log.Tracef("Command %s not found in PATH, using C:\\windows\\system32\\%s.exe path", command, command)
|
||||
|
||||
return "C:\\windows\\system32\\" + command + ".exe"
|
||||
}
|
||||
|
||||
@@ -6,27 +6,14 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/netbirdio/netbird/client/internal/wincmd"
|
||||
)
|
||||
|
||||
func (w *WGIface) Destroy() error {
|
||||
netshCmd := GetSystem32Command("netsh")
|
||||
netshCmd := wincmd.System32("netsh")
|
||||
out, err := exec.Command(netshCmd, "interface", "set", "interface", w.Name(), "admin=disable").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove interface %s: %w - %s", w.Name(), err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSystem32Command checks if a command can be found in the system path and returns it. In case it can't find it
|
||||
// in the path it will return the full path of a command assuming C:\windows\system32 as the base path.
|
||||
func GetSystem32Command(command string) string {
|
||||
_, err := exec.LookPath(command)
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
|
||||
log.Tracef("Command %s not found in PATH, using C:\\windows\\system32\\%s.exe path", command, command)
|
||||
|
||||
return "C:\\windows\\system32\\" + command + ".exe"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// EnvMaxRecvMsgSize overrides the default gRPC max receive message size for
|
||||
// connections to the daemon. Value is in bytes.
|
||||
EnvMaxRecvMsgSize = "NB_DAEMON_GRPC_MAX_MSG_SIZE"
|
||||
|
||||
// defaultMaxRecvMsgSize is the max gRPC receive message size used for daemon
|
||||
// connections when EnvMaxRecvMsgSize is unset or invalid. It overrides the
|
||||
// gRPC library default of 4 MB, which a detailed status already exceeds on a
|
||||
// network of a few thousand peers.
|
||||
defaultMaxRecvMsgSize = 1024 * 1024 * 16
|
||||
)
|
||||
|
||||
// MaxRecvMsgSize returns the max gRPC receive message size for daemon connections
|
||||
// from the environment, or defaultMaxRecvMsgSize (16 MB) if unset or invalid.
|
||||
func MaxRecvMsgSize() int {
|
||||
val := os.Getenv(EnvMaxRecvMsgSize)
|
||||
if val == "" {
|
||||
return defaultMaxRecvMsgSize
|
||||
}
|
||||
|
||||
size, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
log.Warnf("invalid %s value %q, using default: %v", EnvMaxRecvMsgSize, val, err)
|
||||
return defaultMaxRecvMsgSize
|
||||
}
|
||||
|
||||
if size <= 0 {
|
||||
log.Warnf("invalid %s value %d, must be positive, using default", EnvMaxRecvMsgSize, size)
|
||||
return defaultMaxRecvMsgSize
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func TestMaxRecvMsgSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
expected int
|
||||
}{
|
||||
{name: "unset returns default", envValue: "", expected: defaultMaxRecvMsgSize},
|
||||
{name: "non-numeric returns default", envValue: "abc", expected: defaultMaxRecvMsgSize},
|
||||
{name: "negative returns default", envValue: "-1", expected: defaultMaxRecvMsgSize},
|
||||
{name: "zero returns default", envValue: "0", expected: defaultMaxRecvMsgSize},
|
||||
{name: "valid value is used", envValue: "33554432", expected: 33554432},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set first so the previous value is restored on cleanup, then unset to
|
||||
// exercise the absent case.
|
||||
t.Setenv(EnvMaxRecvMsgSize, tc.envValue)
|
||||
if tc.envValue == "" {
|
||||
require.NoError(t, os.Unsetenv(EnvMaxRecvMsgSize), "unset the override")
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, MaxRecvMsgSize(), "max receive message size")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bigStatusServer answers Status with a response larger than gRPC's 4 MB default
|
||||
// receive limit, which is what a detailed status on a large network looks like.
|
||||
type bigStatusServer struct {
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
payload string
|
||||
}
|
||||
|
||||
func (s *bigStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) {
|
||||
return &proto.StatusResponse{Status: s.payload}, nil
|
||||
}
|
||||
|
||||
func startBigStatusServer(t *testing.T, payload string) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "listen on loopback")
|
||||
|
||||
srv := grpc.NewServer()
|
||||
proto.RegisterDaemonServiceServer(srv, &bigStatusServer{payload: payload})
|
||||
go func() {
|
||||
_ = srv.Serve(listener)
|
||||
}()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
return "tcp://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
func TestDialTargetAcceptsAStatusOverTheGrpcDefault(t *testing.T) {
|
||||
payload := strings.Repeat("x", 5*1024*1024)
|
||||
addr := startBigStatusServer(t, payload)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
target, opts := DialTarget(addr)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
require.NoError(t, err, "dial the daemon")
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{})
|
||||
require.NoError(t, err, "a detailed status must not be rejected for its size")
|
||||
assert.Len(t, resp.GetStatus(), len(payload), "the whole response must arrive")
|
||||
}
|
||||
|
||||
// TestDialTargetRaisesTheDefaultLimit is the negative control: the same response
|
||||
// over a connection carrying gRPC's own defaults is refused, which is the failure
|
||||
// reported by `netbird status -d` on a large deployment.
|
||||
func TestDialTargetRaisesTheDefaultLimit(t *testing.T) {
|
||||
payload := strings.Repeat("x", 5*1024*1024)
|
||||
addr := startBigStatusServer(t, payload)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err, "dial with the library defaults")
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_, err = proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{})
|
||||
require.Error(t, err, "the library default must reject this response")
|
||||
assert.Equal(t, codes.ResourceExhausted, status.Code(err), "gRPC rejects an oversized message")
|
||||
}
|
||||
@@ -36,7 +36,10 @@ const (
|
||||
// address. The npipe scheme needs a context dialer because gRPC has no
|
||||
// named-pipe resolver; unix and tcp are handled by gRPC itself.
|
||||
func DialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(MaxRecvMsgSize())),
|
||||
}
|
||||
|
||||
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
|
||||
paths := PipePaths(name)
|
||||
|
||||
@@ -124,19 +124,9 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var useGPO bool
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
} else {
|
||||
closer(k)
|
||||
useGPO = true
|
||||
log.Infof("detected GPO DNS policy configuration, using policy store")
|
||||
}
|
||||
|
||||
configurator := ®istryConfigurator{
|
||||
guid: guid,
|
||||
gpo: useGPO,
|
||||
gpo: useGPOPolicyStore(),
|
||||
}
|
||||
|
||||
origNameservers, err := configurator.captureOriginalNameservers()
|
||||
@@ -576,14 +566,22 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteInterfaceRegistryKeyProperty removes a value from the interface key.
|
||||
// A value that is already gone, or an interface key that is, is not an error:
|
||||
// the caller asked for the value not to be there, and a cleanup that runs twice
|
||||
// has to reach its later steps on the second run as well.
|
||||
func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey string) error {
|
||||
regKey, err := r.getInterfaceRegistryKey()
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
log.Debugf("interface key of %s does not exist, nothing to delete %s from", r.guid, propertyKey)
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("get interface registry key: %w", err)
|
||||
}
|
||||
defer closer(regKey)
|
||||
|
||||
if err := regKey.DeleteValue(propertyKey); err != nil {
|
||||
if err := regKey.DeleteValue(propertyKey); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return fmt.Errorf("delete registry key %s: %w", propertyKey, err)
|
||||
}
|
||||
return nil
|
||||
@@ -612,7 +610,12 @@ func (r *registryConfigurator) restoreHostDNS() error {
|
||||
|
||||
go r.flushDNSCache()
|
||||
|
||||
return nil
|
||||
// Last, and only on the way out, once no rule of ours is left: during a
|
||||
// session the store is where the rules of this run live, and emptying it
|
||||
// mid-session would have the next rule recreate it anyway. Propagated so a
|
||||
// failure keeps the shutdown state for the next run to retry, rather than
|
||||
// leaving the store to hold up every rule change from here on.
|
||||
return removeEmptyGPOPolicyStore()
|
||||
}
|
||||
|
||||
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
|
||||
@@ -651,6 +654,73 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
|
||||
return r.restoreHostDNS()
|
||||
}
|
||||
|
||||
// useGPOPolicyStore reports whether NRPT rules have to go into the group policy
|
||||
// store, and clears an empty one out of the way first.
|
||||
//
|
||||
// The order is the point. A store left empty by an earlier run would otherwise
|
||||
// decide this run too, sending its rules somewhere the resolver only reads when
|
||||
// the policy engine next applies DNS client policy. Removing it before the
|
||||
// choice is made leaves the local store authoritative for the whole session,
|
||||
// including the first one after an upgrade.
|
||||
func useGPOPolicyStore() bool {
|
||||
if err := removeEmptyGPOPolicyStore(); err != nil {
|
||||
// Nothing to retry against here: the worst case is the run going
|
||||
// through the group policy store, which is where it would have gone
|
||||
// before this check existed.
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
return false
|
||||
}
|
||||
closer(k)
|
||||
|
||||
log.Infof("detected GPO DNS policy configuration, using policy store")
|
||||
return true
|
||||
}
|
||||
|
||||
// removeEmptyGPOPolicyStore deletes the group policy DnsPolicyConfig key once
|
||||
// nothing is left in it. The key survives the deletion of the last rule it
|
||||
// held, and the client treats its presence as "group policy configures the
|
||||
// NRPT", so an empty one left behind keeps every later run writing rules there.
|
||||
// Rules in that store reach the resolver only when the policy engine next
|
||||
// applies DNS client policy, and a rule this client writes belongs to no GPO,
|
||||
// so nothing schedules that application: both adding and removing a rule are
|
||||
// held up by a minute or more, and for a removal that is a catch-all rule
|
||||
// resolving every name over an interface that no longer exists. With the store
|
||||
// absent the local one is authoritative and a change applies at once.
|
||||
//
|
||||
// A store that still holds rules, values or subkeys of somebody else's is left
|
||||
// alone.
|
||||
func removeEmptyGPOPolicyStore() error {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
info, err := k.Stat()
|
||||
closer(k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
if info.SubKeyCount != 0 || info.ValueCount != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot); err != nil {
|
||||
return fmt.Errorf("delete empty HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
|
||||
}
|
||||
|
||||
log.Infof("removed the empty GPO DNS policy store, leaving the local one authoritative")
|
||||
return nil
|
||||
}
|
||||
|
||||
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
|
||||
// root. An absent root holds nothing to clean up, which is the normal state of
|
||||
// the GPO store on a machine without DNS Client policy.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/winregistry"
|
||||
)
|
||||
|
||||
// TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up
|
||||
@@ -405,3 +407,130 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveEmptyGPOPolicyStore verifies that cleanup takes the GPO policy
|
||||
// store itself with it once our rules are gone, since the store existing keeps
|
||||
// the local one from being applied, and that a store with somebody else's rule
|
||||
// in it is left alone.
|
||||
func TestRemoveEmptyGPOPolicyStore(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
t.Cleanup(func() { cleanupRegistryKeys(t) })
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
testIP := netip.MustParseAddr("100.64.0.1")
|
||||
cfg := ®istryConfigurator{gpo: true}
|
||||
|
||||
// a store holding a rule of ours is kept, because the rule is still applied
|
||||
require.NoError(t, cfg.addDNSMatchPolicy([]string{".example.com"}, testIP))
|
||||
exists, err := registryKeyExists(gpoDnsPolicyConfigMatchPath + "-0")
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists, "Should write the rule to the GPO policy store")
|
||||
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a policy store that still holds a rule")
|
||||
|
||||
// once the rules are gone the store goes with them
|
||||
require.NoError(t, cfg.removeDNSMatchPolicies())
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Should remove the GPO policy store once it is empty")
|
||||
|
||||
// A store is not ours to remove while somebody else has a rule in it. The
|
||||
// rule is written volatile like our own: the rules above created the parent
|
||||
// chain volatile, and Windows refuses a stable subkey under a volatile
|
||||
// parent.
|
||||
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
|
||||
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create a foreign GPO rule")
|
||||
foreignKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
|
||||
})
|
||||
|
||||
require.NoError(t, cfg.removeDNSMatchPolicies())
|
||||
require.NoError(t, removeEmptyGPOPolicyStore())
|
||||
|
||||
exists, err = registryKeyExists(foreignRule)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should not remove a foreign rule")
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a policy store that still holds a foreign rule")
|
||||
}
|
||||
|
||||
// TestDeleteInterfaceRegistryKeyPropertyTwice verifies that removing a value
|
||||
// that is already gone, or one on an interface key that is, reports success.
|
||||
// Teardown runs again after a failed cleanup, and the steps that follow this
|
||||
// one have to be reached on that second run.
|
||||
func TestDeleteInterfaceRegistryKeyPropertyTwice(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath)
|
||||
})
|
||||
|
||||
cfg := ®istryConfigurator{guid: testGUID}
|
||||
|
||||
require.NoError(t, cfg.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, "example.com"))
|
||||
require.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey))
|
||||
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
|
||||
"Should report success for a value that is already gone")
|
||||
|
||||
// and with the interface key itself gone, as it is once the adapter is
|
||||
require.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath))
|
||||
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
|
||||
"Should report success when the interface key does not exist")
|
||||
}
|
||||
|
||||
// TestUseGPOPolicyStoreClearsEmptyStore verifies that the store is cleared
|
||||
// before it is consulted, so an empty one left by an earlier run does not send
|
||||
// this run's rules to the group policy store. A store somebody else has a rule
|
||||
// in still decides where the rules go.
|
||||
func TestUseGPOPolicyStoreClearsEmptyStore(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
t.Cleanup(func() { cleanupRegistryKeys(t) })
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
// the leftover an earlier run used to keep, which the client read as
|
||||
// "group policy configures the NRPT" for every run after it
|
||||
emptyStore, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create the GPO policy store")
|
||||
emptyStore.Close()
|
||||
|
||||
assert.False(t, useGPOPolicyStore(), "An empty store should not decide where the rules go")
|
||||
exists, err := registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Should clear the empty store before consulting it")
|
||||
|
||||
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
|
||||
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create a foreign GPO rule")
|
||||
foreignKey.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
|
||||
})
|
||||
|
||||
assert.True(t, useGPOPolicyStore(), "A store holding a rule should decide where the rules go")
|
||||
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should keep a store that holds a rule")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,17 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// CheckOnlyOwnerWritable reports an error unless path, and every directory
|
||||
// leading to it, is owned by an account that can already act with the privileges
|
||||
// the caller holds, and is writable by nobody else.
|
||||
//
|
||||
// Exported for callers outside elevation that read a file while privileged and
|
||||
// then act on what it says: the same question this package asks of an
|
||||
// executable, asked of a configuration file.
|
||||
func CheckOnlyOwnerWritable(path string) error {
|
||||
return checkOnlyOwnerWritable(path)
|
||||
}
|
||||
|
||||
// trustedSelf returns the path of this executable, provided it is one we are
|
||||
// willing to have run as root.
|
||||
//
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/stun/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -244,6 +245,12 @@ type Engine struct {
|
||||
|
||||
wgInterface WGIface
|
||||
|
||||
// wgDevice is a lock-free handle on the WireGuard device behind
|
||||
// wgInterface. Reaching the device through wgInterface requires
|
||||
// syncMsgMux, which handleSync holds while it adds and removes peers;
|
||||
// SetPerformance must stay reachable exactly when that work is stuck.
|
||||
wgDevice atomic.Pointer[wgdevice.Device]
|
||||
|
||||
udpMux *udpmux.UniversalUDPMuxDefault
|
||||
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
@@ -666,6 +673,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
|
||||
return fmt.Errorf("up wg interface: %w", err)
|
||||
}
|
||||
e.wgDevice.Store(e.wgInterface.GetWGDevice())
|
||||
|
||||
// Set up notrack rules immediately after proxy is listening to prevent
|
||||
// conntrack entries from being created before the rules are in place
|
||||
@@ -1067,7 +1075,11 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
// back to empty if the FQDN doesn't have the expected shape.
|
||||
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
|
||||
}
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
|
||||
// With the firewall disabled there is no ACL manager to program, so
|
||||
// RoutesFirewallRules would be built and then dropped. On a peer that
|
||||
// routes many network resources that is the single most expensive
|
||||
// step of the sync.
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName, e.config.DisableFirewall)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode network map envelope: %w", err)
|
||||
}
|
||||
@@ -2195,6 +2207,10 @@ func (e *Engine) close() {
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
if e.wgInterface != nil {
|
||||
// Drop the handle before the close starts: a retune that loads it
|
||||
// afterwards would touch a device on its way out and report success
|
||||
// for an engine that is already gone.
|
||||
e.wgDevice.Store(nil)
|
||||
if err := e.wgInterface.Close(); err != nil {
|
||||
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
|
||||
}
|
||||
@@ -2354,15 +2370,16 @@ type Performance struct {
|
||||
}
|
||||
|
||||
// SetPerformance applies the given tuning to this engine's live Device.
|
||||
//
|
||||
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
|
||||
// recovery path for a device whose pool is exhausted, and an exhausted pool
|
||||
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
|
||||
// it stays blocked. Taking the lock here would make the retune unreachable in
|
||||
// the one situation that needs it.
|
||||
func (e *Engine) SetPerformance(t Performance) error {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.wgInterface == nil {
|
||||
return fmt.Errorf("wg interface not initialized")
|
||||
}
|
||||
dev := e.wgInterface.GetWGDevice()
|
||||
dev := e.wgDevice.Load()
|
||||
if dev == nil {
|
||||
return fmt.Errorf("wg device not initialized")
|
||||
return errors.New("wg device not initialized")
|
||||
}
|
||||
if t.PreallocatedBuffersPerPool != nil {
|
||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||
|
||||
@@ -135,9 +135,10 @@ type Conn struct {
|
||||
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
|
||||
rosenpassRemoteKey []byte
|
||||
|
||||
wgProxyICE wgproxy.Proxy
|
||||
wgProxyRelay wgproxy.Proxy
|
||||
handshaker *Handshaker
|
||||
wgProxyICE wgproxy.Proxy
|
||||
wgProxyRelay wgproxy.Proxy
|
||||
relayedConnRef *relayClient.Conn
|
||||
handshaker *Handshaker
|
||||
|
||||
guard *guard.Guard
|
||||
wg sync.WaitGroup
|
||||
@@ -560,7 +561,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
if conn.ctx.Err() != nil {
|
||||
if conn.ctx.Err() != nil || rci.relayedConn.Context().Err() != nil {
|
||||
if err := rci.relayedConn.Close(); err != nil {
|
||||
conn.Log.Warnf("failed to close unnecessary relayed connection: %v", err)
|
||||
}
|
||||
@@ -575,7 +576,9 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
|
||||
return
|
||||
}
|
||||
wgProxy.SetDisconnectListener(conn.onRelayDisconnected)
|
||||
wgProxy.SetDisconnectListener(func() {
|
||||
conn.onRelayDisconnected(rci.relayedConn)
|
||||
})
|
||||
|
||||
conn.dumpState.NewLocalProxy()
|
||||
|
||||
@@ -583,7 +586,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
|
||||
if conn.isICEActive() {
|
||||
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
|
||||
conn.setRelayedProxy(wgProxy)
|
||||
conn.setRelayedProxy(wgProxy, rci.relayedConn)
|
||||
conn.statusRelay.SetConnected()
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
|
||||
return
|
||||
@@ -614,15 +617,26 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
conn.rosenpassRemoteKey = rci.rosenpassPubKey
|
||||
conn.currentConnPriority = conntype.Relay
|
||||
conn.statusRelay.SetConnected()
|
||||
conn.setRelayedProxy(wgProxy)
|
||||
conn.setRelayedProxy(wgProxy, rci.relayedConn)
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime)
|
||||
conn.Log.Infof("start to communicate with peer via relay")
|
||||
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime)
|
||||
}
|
||||
|
||||
func (conn *Conn) onRelayDisconnected() {
|
||||
// onRelayDisconnected reports the teardown of a relayed connection. relayedConn
|
||||
// names the connection the signal belongs to, so a signal that arrives after
|
||||
// its connection was replaced is ignored instead of tearing down its successor.
|
||||
// A nil relayedConn means the caller does not track generations and the current
|
||||
// connection is always torn down.
|
||||
func (conn *Conn) onRelayDisconnected(relayedConn *relayClient.Conn) {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
if relayedConn != nil && conn.relayedConnRef != relayedConn {
|
||||
conn.Log.Debugf("ignoring relay disconnect of a superseded connection")
|
||||
return
|
||||
}
|
||||
|
||||
conn.handleRelayDisconnectedLocked()
|
||||
}
|
||||
|
||||
@@ -646,6 +660,7 @@ func (conn *Conn) handleRelayDisconnectedLocked() {
|
||||
_ = conn.wgProxyRelay.CloseConn()
|
||||
conn.wgProxyRelay = nil
|
||||
}
|
||||
conn.relayedConnRef = nil
|
||||
|
||||
changed := conn.statusRelay.Get() != worker.StatusDisconnected
|
||||
if changed {
|
||||
@@ -930,13 +945,14 @@ func (conn *Conn) logTraceConnState() {
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
|
||||
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy, relayedConn *relayClient.Conn) {
|
||||
if conn.wgProxyRelay != nil {
|
||||
if err := conn.wgProxyRelay.CloseConn(); err != nil {
|
||||
conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err)
|
||||
}
|
||||
}
|
||||
conn.wgProxyRelay = proxy
|
||||
conn.relayedConnRef = relayedConn
|
||||
}
|
||||
|
||||
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
|
||||
|
||||
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case remoteOfferAnswer := <-h.remoteOffersCh:
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
case remoteOfferAnswer := <-h.remoteAnswerCh:
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
}
|
||||
|
||||
@@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
// "none" would blank the UI at the exact moment it should say the session
|
||||
// ended.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -14,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type RelayConnInfo struct {
|
||||
relayedConn net.Conn
|
||||
relayedConn *relayClient.Conn
|
||||
rosenpassPubKey []byte
|
||||
rosenpassAddr string
|
||||
}
|
||||
@@ -27,7 +26,7 @@ type WorkerRelay struct {
|
||||
conn *Conn
|
||||
relayManager *relayClient.Manager
|
||||
|
||||
relayedConn net.Conn
|
||||
relayedConn *relayClient.Conn
|
||||
relayLock sync.Mutex
|
||||
|
||||
relaySupportedOnRemotePeer atomic.Bool
|
||||
@@ -80,12 +79,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.relayedConn = relayedConn
|
||||
w.relayLock.Unlock()
|
||||
|
||||
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add close listener: %s", err)
|
||||
_ = relayedConn.Close()
|
||||
return
|
||||
}
|
||||
go w.watchRelayedConn(relayedConn)
|
||||
|
||||
w.log.Debugf("peer conn opened via Relay: %s", srv)
|
||||
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
|
||||
@@ -109,12 +103,15 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool {
|
||||
|
||||
func (w *WorkerRelay) CloseConn() {
|
||||
w.relayLock.Lock()
|
||||
defer w.relayLock.Unlock()
|
||||
if w.relayedConn == nil {
|
||||
conn := w.relayedConn
|
||||
w.relayedConn = nil
|
||||
w.relayLock.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.relayedConn.Close(); err != nil {
|
||||
if err := conn.Close(); err != nil {
|
||||
w.log.Warnf("failed to close relay connection: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +130,8 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
|
||||
return remoteRelayAddress
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) onRelayClientDisconnected() {
|
||||
go w.conn.onRelayDisconnected()
|
||||
func (w *WorkerRelay) watchRelayedConn(relayedConn *relayClient.Conn) {
|
||||
<-relayedConn.Context().Done()
|
||||
|
||||
w.conn.onRelayDisconnected(relayedConn)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Regression test: a concurrent Get and Set of the ActiveProfileState will
|
||||
// fail on Windows since the write is a temp file renamed over an open file.
|
||||
// Windows will refuse to replace a file another handle holds open by default.
|
||||
func TestActiveProfileState_ReadsDoNotBreakAConcurrentWrite(t *testing.T) {
|
||||
withTempConfigDir(t, func(configDir string) {
|
||||
withPatchedGlobals(t, configDir, func() {
|
||||
sm := &ServiceManager{}
|
||||
require.NoError(t, sm.CreateDefaultProfile())
|
||||
require.NoError(t, sm.SetActiveProfileStateToDefault())
|
||||
|
||||
const switched = ID("0123456789abcdef0123456789abcdef")
|
||||
const rounds = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 128)
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for r := 0; r < rounds; r++ {
|
||||
state, err := sm.GetActiveProfileState()
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("read: %w", err)
|
||||
return
|
||||
}
|
||||
if state.ID != defaultProfileName && state.ID != switched {
|
||||
errs <- fmt.Errorf("read: active profile is %q, which no writer wrote", state.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for r := 0; r < rounds; r++ {
|
||||
id := switched
|
||||
if r%2 == 0 {
|
||||
id = defaultProfileName
|
||||
}
|
||||
if err := sm.SetActiveProfileState(&ActiveProfileState{ID: id, Username: "testuser"}); err != nil {
|
||||
errs <- fmt.Errorf("switch: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
assert.NoError(t, err, "a switch and a read of the active profile state must not collide")
|
||||
}
|
||||
|
||||
state, err := sm.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, []ID{defaultProfileName, switched}, state.ID,
|
||||
"the file holds whichever switch landed last, not a mix of the two")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package wincmd locates the Windows utilities the client shells out to.
|
||||
package wincmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// defaultSystem32Dir is where the system directory is on every supported
|
||||
// install, used only when the API that reports it fails.
|
||||
const defaultSystem32Dir = `C:\Windows\System32`
|
||||
|
||||
// System32 returns the full path of a Windows utility under the system
|
||||
// directory.
|
||||
//
|
||||
// PATH is deliberately not consulted. The daemon runs as LocalSystem with an
|
||||
// environment of its own, so whoever can place an entry in that PATH chooses
|
||||
// which binary runs with those privileges. The system directory is read from
|
||||
// the API rather than from %SystemRoot% for the same reason.
|
||||
func System32(command string) string {
|
||||
sysDir, err := windows.GetSystemDirectory()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to locate the Windows system directory, falling back to %s: %v", defaultSystem32Dir, err)
|
||||
sysDir = defaultSystem32Dir
|
||||
}
|
||||
|
||||
return filepath.Join(sysDir, command+".exe")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package wincmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSystem32IgnoresPATH(t *testing.T) {
|
||||
// A directory holding something that would win a PATH lookup, in front of
|
||||
// everything else: the daemon runs as LocalSystem, so a PATH entry must not
|
||||
// be able to decide what it executes.
|
||||
planted := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(planted, "netsh.exe"), []byte("not really netsh"), 0o600))
|
||||
t.Setenv("PATH", planted+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
got := System32("netsh")
|
||||
|
||||
assert.True(t, filepath.IsAbs(got), "the path must be absolute, got %q", got)
|
||||
assert.NotContains(t, got, planted, "a PATH entry must not be consulted")
|
||||
assert.True(t, strings.EqualFold(filepath.Base(got), "netsh.exe"), "unexpected file name in %q", got)
|
||||
|
||||
// The system directory is what Windows reports it to be, not %SystemRoot%,
|
||||
// which the same caller could have set alongside PATH.
|
||||
t.Setenv("SystemRoot", planted)
|
||||
assert.Equal(t, got, System32("netsh"), "%SystemRoot% must not move the lookup")
|
||||
}
|
||||
@@ -6,7 +6,25 @@
|
||||
<title>NetBird</title>
|
||||
<style>
|
||||
html, body { background: #181A1D; }
|
||||
html:not(.dark), html:not(.dark) body { background: #F3F3F3; }
|
||||
</style>
|
||||
<script>
|
||||
// Pre-paint theme guard: apply the last-known theme before first render
|
||||
// to avoid a flash of the wrong theme. ThemeContext keeps the mirror
|
||||
// fresh from the persisted preference and the Go-reported OS appearance.
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem("nb-theme-pref") || "system";
|
||||
var dark;
|
||||
if (pref === "dark") dark = true;
|
||||
else if (pref === "light") dark = false;
|
||||
else dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
} catch (e) {
|
||||
/* keep the default dark class */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
import { initPlatform } from "@/lib/platform";
|
||||
import { initLogForwarding } from "@/lib/logs";
|
||||
@@ -35,30 +36,38 @@ Promise.all([
|
||||
]).finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route path={"install-progress"} element={<UpdateInProgressDialog />} />
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
<ThemeProvider>
|
||||
<SkeletonTheme
|
||||
baseColor={"rgb(var(--skeleton-base))"}
|
||||
highlightColor={"rgb(var(--skeleton-highlight))"}
|
||||
>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"install-progress"}
|
||||
element={<UpdateInProgressDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#1f2124"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#1f2124"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#1f2124"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#1f2124"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#1f2124"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#1f2124"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#1f2124"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -11,12 +11,14 @@ type Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
info: "bg-sky-900 border border-sky-700 text-sky-200",
|
||||
info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200",
|
||||
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
|
||||
success: "bg-green-900 border border-green-700 text-green-200",
|
||||
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
|
||||
danger: "bg-red-900 border border-red-700 text-red-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird",
|
||||
success:
|
||||
"bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200",
|
||||
warning:
|
||||
"bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200",
|
||||
danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200",
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
|
||||
|
||||
@@ -81,7 +81,7 @@ export const CopyToClipboard = ({
|
||||
aria-live={"polite"}
|
||||
className={cn(
|
||||
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ const menuItemVariants = cva("", {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
|
||||
danger: "text-red-500 hover:bg-red-500/10 hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 dark:hover:bg-red-900/20 dark:focus-visible:bg-red-900/20",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
|
||||
@@ -97,9 +97,9 @@ export function LanguagePicker() {
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -157,7 +157,7 @@ export function LanguagePicker() {
|
||||
placeholder={t("settings.general.language.search")}
|
||||
aria-label={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300",
|
||||
"border-none outline-none",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/cn";
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
default: "text-nb-gray-50",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
@@ -27,7 +27,7 @@ export const SquareIcon = ({
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-900 bg-nb-gray-920",
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-800 bg-nb-gray-920 dark:border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ value: "system", icon: MonitorIcon, labelKey: "settings.general.theme.system" },
|
||||
{ value: "light", icon: SunMediumIcon, labelKey: "settings.general.theme.light" },
|
||||
{ value: "dark", icon: MoonIcon, labelKey: "settings.general.theme.dark" },
|
||||
];
|
||||
|
||||
export function ThemePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
|
||||
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
|
||||
const CurrentIcon = current.icon;
|
||||
|
||||
const select = async (value: string) => {
|
||||
if (busy || value === theme) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setTheme(value as ThemePreference);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>{t("settings.general.theme.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.general.theme.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<CurrentIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"flex-1 truncate text-left"}>
|
||||
{t(current.labelKey)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"end"}
|
||||
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
|
||||
>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
|
||||
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"mr-2 shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,12 +81,12 @@ export const Tooltip = ({
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-sm dark: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",
|
||||
"rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -46,12 +46,12 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-lg px-2 py-2.5 text-left outline-none",
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,13 +61,15 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"ml-2 shrink-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:font-semibold group-data-[state=active]:text-nb-gray-100 dark:group-data-[state=active]:font-medium",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -24,71 +24,74 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700",
|
||||
],
|
||||
secondary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
subtle: [
|
||||
"border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
|
||||
"border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||
"dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||
],
|
||||
input: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
tertiary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
outline: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
"danger-outline": [
|
||||
"bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50",
|
||||
"dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
|
||||
],
|
||||
"danger-text": [
|
||||
"rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
"rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
"data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50",
|
||||
],
|
||||
ghost: [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
],
|
||||
danger: [
|
||||
"bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20",
|
||||
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||
"bg-black/60",
|
||||
"bg-black/25 dark:bg-black/60",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
@@ -67,7 +67,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
className={cn(
|
||||
"relative z-[52] mx-auto w-full outline-none ring-0",
|
||||
"focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl",
|
||||
"rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
|
||||
exitAnimation &&
|
||||
|
||||
@@ -32,19 +32,19 @@ const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
@@ -158,7 +158,7 @@ function NumberStepper({
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
)}
|
||||
@@ -274,7 +274,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
@@ -303,7 +305,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-400",
|
||||
"border-none outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function FancyToggleSwitch({
|
||||
|
||||
if (loading) {
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
"text-transparent select-none rounded bg-nb-gray-920 box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div
|
||||
role={"status"}
|
||||
@@ -58,7 +58,9 @@ export default function FancyToggleSwitch({
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"h-[24px] w-[44px] animate-pulse rounded-full bg-[#25282d]"}
|
||||
className={
|
||||
"h-[24px] w-[44px] animate-pulse rounded-full bg-nb-gray-920"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||
"cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
@@ -30,7 +30,9 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={layoutId}
|
||||
className={"absolute inset-0 rounded-md bg-nb-gray-700"}
|
||||
className={
|
||||
"absolute inset-0 rounded-md bg-white shadow-sm dark:bg-nb-gray-700 dark:shadow-none"
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SwitchItemGroup = ({
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={cn(
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-910 p-1 dark:border-nb-gray-850",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -18,8 +18,8 @@ const switchVariants = cva("", {
|
||||
default: [
|
||||
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||
"data-[state=checked]:bg-netbird data-[state=unchecked]:bg-nb-gray-700",
|
||||
"data-[state=checked]:hover:bg-netbird-500 data-[state=unchecked]:hover:bg-nb-gray-600/60",
|
||||
],
|
||||
"red-green": [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
@@ -52,7 +52,7 @@ const ToggleSwitch = React.forwardRef<
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"block text-[.81rem] font-light tracking-wide transition-all duration-300 dark:text-nb-gray-300",
|
||||
"block text-[.81rem] font-light tracking-wide text-nb-gray-300 transition-all duration-300",
|
||||
margin && "mb-2",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Preferences, Theme } from "@bindings/services";
|
||||
import { type Theme as ThemePref, type UIPreferences } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const PREF_KEY = "nb-theme-pref";
|
||||
const SYSTEM_KEY = "nb-system-dark";
|
||||
|
||||
const isPreference = (v: unknown): v is ThemePreference =>
|
||||
v === "system" || v === "light" || v === "dark";
|
||||
|
||||
const initialSystemDark = (): boolean => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(SYSTEM_KEY);
|
||||
if (mirrored !== null) return mirrored === "true";
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
};
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemePreference;
|
||||
resolvedDark: boolean;
|
||||
setTheme: (theme: ThemePreference) => Promise<void>;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setThemeState] = useState<ThemePreference>(() => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(PREF_KEY);
|
||||
if (isPreference(mirrored)) return mirrored;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return "system";
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState<boolean>(initialSystemDark);
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
// Blocks the initial Preferences.Get snapshot from overwriting newer updates.
|
||||
const supersededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled || supersededRef.current) return;
|
||||
if (isPreference(prefs?.theme)) setThemeState(prefs.theme);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] load preferences failed", err));
|
||||
Theme.SystemDarkMode()
|
||||
.then((dark) => {
|
||||
if (!cancelled) setSystemDark(dark);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] SystemDarkMode failed", err));
|
||||
|
||||
// Cross-window sync: a flip in the settings window reaches every window.
|
||||
const offPrefs = Events.On("netbird:preferences:changed", (e: { data?: UIPreferences }) => {
|
||||
if (isPreference(e.data?.theme)) {
|
||||
supersededRef.current = true;
|
||||
setThemeState(e.data.theme);
|
||||
}
|
||||
});
|
||||
const offSystem = Events.On(
|
||||
"netbird:system-theme:changed",
|
||||
(e: { data?: { dark?: boolean } }) => {
|
||||
if (typeof e.data?.dark === "boolean") setSystemDark(e.data.dark);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offPrefs();
|
||||
offSystem();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedDark = theme === "dark" || (theme === "system" && systemDark);
|
||||
|
||||
// Apply the class and refresh the pre-paint mirror (index.html reads it).
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", resolvedDark);
|
||||
try {
|
||||
localStorage.setItem(PREF_KEY, theme);
|
||||
localStorage.setItem(SYSTEM_KEY, String(systemDark));
|
||||
} catch {
|
||||
/* mirror is best-effort */
|
||||
}
|
||||
}, [theme, systemDark, resolvedDark]);
|
||||
|
||||
// Optimistic; reverts on persist failure so UI matches the stored pref.
|
||||
const setTheme = useCallback(async (next: ThemePreference) => {
|
||||
const prev = themeRef.current;
|
||||
supersededRef.current = true;
|
||||
setThemeState(next);
|
||||
try {
|
||||
await Preferences.SetTheme(next as ThemePref);
|
||||
} catch (err) {
|
||||
setThemeState(prev);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedDark, setTheme }),
|
||||
[theme, resolvedDark, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
|
||||
return ctx;
|
||||
};
|
||||
@@ -16,6 +16,66 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* nb-gray channels (space-separated RGB) consumed by tailwind.config.ts.
|
||||
:root is the inverted light ramp — high stops are surfaces (near-white),
|
||||
low stops are text (near-black); .dark restores the original dark ramp.
|
||||
Surfaces stay darker than content cards to preserve their separation. */
|
||||
:root {
|
||||
--nb-gray-DEFAULT: 243 243 243;
|
||||
--nb-gray-50: 26 26 26;
|
||||
--nb-gray-100: 33 33 33;
|
||||
--nb-gray-200: 50 50 50;
|
||||
--nb-gray-250: 58 58 58;
|
||||
--nb-gray-300: 77 77 77;
|
||||
--nb-gray-350: 92 92 92;
|
||||
--nb-gray-400: 108 108 108;
|
||||
--nb-gray-500: 135 135 135;
|
||||
--nb-gray-600: 154 154 154;
|
||||
--nb-gray-700: 209 209 209;
|
||||
--nb-gray-800: 226 226 226;
|
||||
--nb-gray-850: 234 234 234;
|
||||
--nb-gray-900: 238 238 238;
|
||||
--nb-gray-910: 240 240 240;
|
||||
--nb-gray-920: 243 243 243;
|
||||
--nb-gray-925: 245 245 245;
|
||||
--nb-gray-930: 246 246 246;
|
||||
--nb-gray-935: 248 248 248;
|
||||
--nb-gray-940: 250 250 250;
|
||||
--nb-gray-950: 252 252 252;
|
||||
--nb-gray-960: 255 255 255;
|
||||
|
||||
--skeleton-base: 238 238 238;
|
||||
--skeleton-highlight: 247 247 247;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--nb-gray-DEFAULT: 24 26 29;
|
||||
--nb-gray-50: 244 246 247;
|
||||
--nb-gray-100: 228 231 233;
|
||||
--nb-gray-200: 203 210 214;
|
||||
--nb-gray-250: 183 192 198;
|
||||
--nb-gray-300: 163 173 181;
|
||||
--nb-gray-350: 143 156 168;
|
||||
--nb-gray-400: 124 137 148;
|
||||
--nb-gray-500: 97 110 121;
|
||||
--nb-gray-600: 83 93 103;
|
||||
--nb-gray-700: 71 78 87;
|
||||
--nb-gray-800: 63 68 75;
|
||||
--nb-gray-850: 54 59 64;
|
||||
--nb-gray-900: 46 50 56;
|
||||
--nb-gray-910: 43 47 51;
|
||||
--nb-gray-920: 37 40 45;
|
||||
--nb-gray-925: 30 33 35;
|
||||
--nb-gray-930: 37 40 44;
|
||||
--nb-gray-935: 31 33 36;
|
||||
--nb-gray-940: 28 30 33;
|
||||
--nb-gray-950: 24 26 29;
|
||||
--nb-gray-960: 22 24 27;
|
||||
|
||||
--skeleton-base: 37 40 45;
|
||||
--skeleton-highlight: 51 55 62;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -28,8 +88,9 @@ body,
|
||||
* MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
|
||||
* lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
|
||||
* body alpha meant two machines with different wallpapers saw different
|
||||
* effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
|
||||
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
|
||||
* effective backgrounds. Matching the per-theme Wails BackgroundColour
|
||||
* (services.CurrentWindowBackgroundColour, nb-gray DEFAULT in both ramps)
|
||||
* keeps things consistent regardless of the OS backdrop.
|
||||
*/
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const AppRightPanel = ({ children, overlay, overlayOpen = false, classNam
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -10,8 +10,8 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
if (ms <= 0) return "text-nb-gray-400";
|
||||
if (ms < 100) return "text-green-400";
|
||||
return "text-yellow-400";
|
||||
if (ms < 100) return "text-green-600 dark:text-green-400";
|
||||
return "text-yellow-600 dark:text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLogoLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
@@ -224,10 +225,16 @@ export const MainConnectionStatusSwitch = () => {
|
||||
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogoLight}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none dark:hidden"}
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
|
||||
className={"wails-no-draggable mb-4 hidden h-7 w-auto select-none dark:block"}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -321,7 +328,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
className={cn(
|
||||
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
@@ -395,7 +402,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -156,14 +156,14 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
|
||||
: "cursor-default hover:border-nb-gray-700 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-700 data-[state=open]:bg-nb-gray-935 dark:hover:border-nb-gray-900 dark:data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,9 @@ export const MainHeader = () => {
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
className={"select-none"}
|
||||
className={
|
||||
"select-none hover:bg-nb-gray-800 data-[state=open]:bg-nb-gray-800 dark:hover:bg-nb-gray-900 dark:data-[state=open]:bg-nb-gray-900"
|
||||
}
|
||||
aria-label={t("header.menu.open")}
|
||||
aria-haspopup={"menu"}
|
||||
aria-expanded={menuOpen}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const Navigation = () => {
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export const Networks = () => {
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
|
||||
"wails-no-draggable cursor-pointer outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
@@ -358,7 +358,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
@@ -396,7 +396,8 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
|
||||
"border border-nb-gray-800 bg-white text-nb-gray-300 transition-colors group-hover:border-nb-gray-700",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-920 dark:group-hover:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -461,7 +462,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -54,9 +54,9 @@ const DASH = "-";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
@@ -235,7 +235,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
)}
|
||||
@@ -468,7 +468,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
"border border-nb-gray-900",
|
||||
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
@@ -530,7 +530,7 @@ const ResourceRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -287,7 +287,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"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",
|
||||
@@ -211,11 +211,11 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
aria-haspopup={"listbox"}
|
||||
className={cn(
|
||||
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-800 dark:hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-800 dark:data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ProfilesTab() {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
|
||||
"overflow-hidden rounded-xl border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<ProfilesTable
|
||||
@@ -411,7 +411,7 @@ const ProfileRow = ({
|
||||
"outline-none",
|
||||
isFirst && "rounded-t-xl",
|
||||
isLast && "rounded-b-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
@@ -560,7 +560,7 @@ const RowMoreMenu = ({
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
@@ -654,7 +654,7 @@ const ActionIconButton = ({
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
variant === "danger"
|
||||
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
@@ -90,7 +91,16 @@ export function SettingsAbout() {
|
||||
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
|
||||
<img
|
||||
src={netbirdFullLight}
|
||||
alt={t("common.netbird")}
|
||||
className={"h-7 w-auto dark:hidden"}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFull}
|
||||
alt={t("common.netbird")}
|
||||
className={"hidden h-7 w-auto dark:block"}
|
||||
/>
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
@@ -139,7 +149,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
@@ -157,7 +167,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(link.url)}
|
||||
className={
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { ThemePicker } from "@/components/ThemePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function SettingsGeneral() {
|
||||
<>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<ThemePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SectionGroup = ({
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
"mb-8 rounded-md px-1 outline-none last:mb-1",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
)}
|
||||
>
|
||||
@@ -33,7 +33,7 @@ export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SettingsTroubleshooting() {
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
@@ -277,7 +277,10 @@ function DoneResult({
|
||||
};
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
|
||||
<SquareIcon
|
||||
icon={CircleCheckBig}
|
||||
className={"bg-white dark:bg-nb-gray-920 [&_svg]:text-green-500"}
|
||||
/>
|
||||
|
||||
<div className={"flex max-w-sm flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
@@ -326,7 +329,9 @@ function DoneResult({
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto transition-all hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden={"true"} />
|
||||
@@ -339,7 +344,7 @@ function DoneResult({
|
||||
<div
|
||||
role={"alert"}
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300"
|
||||
}
|
||||
>
|
||||
{result.uploadFailureReason
|
||||
|
||||
@@ -10,29 +10,32 @@ const config: Config = {
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// nb-gray is theme-aware: channels come from CSS variables in
|
||||
// globals.css (:root = light ramp, .dark = original dark ramp). The
|
||||
// rgb(var(...) / <alpha-value>) form keeps opacity modifiers working.
|
||||
"nb-gray": {
|
||||
DEFAULT: "#181A1D",
|
||||
50: "#f4f6f7",
|
||||
100: "#e4e7e9",
|
||||
200: "#cbd2d6",
|
||||
250: "#b7c0c6",
|
||||
300: "#a3adb5",
|
||||
350: "#8f9ca8",
|
||||
400: "#7c8994",
|
||||
500: "#616e79",
|
||||
600: "#535d67",
|
||||
700: "#474e57",
|
||||
800: "#3f444b",
|
||||
850: "#363b40",
|
||||
900: "#2e3238",
|
||||
910: "#2b2f33",
|
||||
920: "#25282d",
|
||||
925: "#1e2123",
|
||||
930: "#25282c",
|
||||
935: "#1f2124",
|
||||
940: "#1c1e21",
|
||||
950: "#181a1d",
|
||||
960: "#16181b",
|
||||
DEFAULT: "rgb(var(--nb-gray-DEFAULT) / <alpha-value>)",
|
||||
50: "rgb(var(--nb-gray-50) / <alpha-value>)",
|
||||
100: "rgb(var(--nb-gray-100) / <alpha-value>)",
|
||||
200: "rgb(var(--nb-gray-200) / <alpha-value>)",
|
||||
250: "rgb(var(--nb-gray-250) / <alpha-value>)",
|
||||
300: "rgb(var(--nb-gray-300) / <alpha-value>)",
|
||||
350: "rgb(var(--nb-gray-350) / <alpha-value>)",
|
||||
400: "rgb(var(--nb-gray-400) / <alpha-value>)",
|
||||
500: "rgb(var(--nb-gray-500) / <alpha-value>)",
|
||||
600: "rgb(var(--nb-gray-600) / <alpha-value>)",
|
||||
700: "rgb(var(--nb-gray-700) / <alpha-value>)",
|
||||
800: "rgb(var(--nb-gray-800) / <alpha-value>)",
|
||||
850: "rgb(var(--nb-gray-850) / <alpha-value>)",
|
||||
900: "rgb(var(--nb-gray-900) / <alpha-value>)",
|
||||
910: "rgb(var(--nb-gray-910) / <alpha-value>)",
|
||||
920: "rgb(var(--nb-gray-920) / <alpha-value>)",
|
||||
925: "rgb(var(--nb-gray-925) / <alpha-value>)",
|
||||
930: "rgb(var(--nb-gray-930) / <alpha-value>)",
|
||||
935: "rgb(var(--nb-gray-935) / <alpha-value>)",
|
||||
940: "rgb(var(--nb-gray-940) / <alpha-value>)",
|
||||
950: "rgb(var(--nb-gray-950) / <alpha-value>)",
|
||||
960: "rgb(var(--nb-gray-960) / <alpha-value>)",
|
||||
},
|
||||
gray: {
|
||||
50: "#F9FAFB",
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Keine Sprachen gefunden."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Erscheinungsbild"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Hell, Dunkel oder die Systemeinstellung verwenden."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Hell"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dunkel"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management-Server"
|
||||
},
|
||||
|
||||
@@ -759,6 +759,26 @@
|
||||
"message": "No languages match.",
|
||||
"description": "Shown when no languages match the search."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Theme",
|
||||
"description": "Label for the appearance/theme picker."
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choose light or dark, or follow your system appearance.",
|
||||
"description": "Helper text for the theme picker."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System",
|
||||
"description": "Theme option: follow the OS appearance."
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Light",
|
||||
"description": "Theme option: light appearance."
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dark",
|
||||
"description": "Theme option: dark appearance."
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management Server",
|
||||
"description": "Label for the management-server selector."
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Ningún idioma coincide."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Elija el tema claro u oscuro, o siga la apariencia del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Oscuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gestión"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Aucune langue ne correspond."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Thème"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choisissez le thème clair ou sombre, ou suivez l'apparence du système."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Système"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Clair"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sombre"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Serveur de gestion"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nincs találat."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Téma"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Válassza a világos vagy sötét témát, vagy kövesse a rendszer beállítását."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Rendszer"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Világos"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sötét"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Felügyeleti szerver"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nessuna lingua corrisponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Scelga il tema chiaro o scuro, oppure segua l'aspetto del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Chiaro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Scuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Server di gestione"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "一致する言語がありません。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "テーマ"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "ライト、ダーク、またはシステムの外観に従います。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "システム"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "ライト"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "ダーク"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理サーバー"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nenhum idioma corresponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Escolha claro, escuro ou siga a aparência do sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Escuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gerenciamento"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Языки не найдены."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Выберите светлую или тёмную тему либо следуйте системной."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системная"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Светлая"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Тёмная"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер управления"
|
||||
},
|
||||
|
||||
@@ -569,6 +569,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Не знайдено жодної мови."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Виберіть світлу чи темну тему або використовуйте системні налаштування."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системна"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Світла"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Темна"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер керування"
|
||||
},
|
||||
@@ -764,7 +779,7 @@
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Приховує IP-адреси, домени та інші конфіденційні дані."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
@@ -1370,7 +1385,7 @@
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Очікування авторизації…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "没有匹配的语言。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "主题"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "选择浅色、深色或跟随系统外观。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "跟随系统"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "浅色"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "深色"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理服务器"
|
||||
},
|
||||
|
||||
+9
-4
@@ -77,6 +77,7 @@ func init() {
|
||||
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
|
||||
application.RegisterEvent[updater.State](updater.EventStateChanged)
|
||||
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
|
||||
application.RegisterEvent[services.SystemTheme](services.EventSystemThemeChanged)
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -100,7 +101,7 @@ func main() {
|
||||
var tray *Tray
|
||||
app := newApplication(func() {
|
||||
if tray != nil {
|
||||
tray.ShowWindow()
|
||||
go tray.ShowWindow()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -123,6 +124,9 @@ func main() {
|
||||
|
||||
bundle, prefStore, localizer := buildI18n(app)
|
||||
|
||||
// Before any window exists so creation-time backgrounds are already themed.
|
||||
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
@@ -354,6 +358,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
|
||||
initialWidth = 900
|
||||
}
|
||||
appearance := services.CurrentAppearance()
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
@@ -363,13 +368,13 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
// drop new windows top-left unless asked.
|
||||
InitialPosition: application.WindowCentered,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
BackgroundColour: services.WindowBackgroundColour(appearance),
|
||||
URL: startURL,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
Mac: services.AppleMacOSAppearanceOptions(),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: services.AppleMacOSAppearanceOptions(appearance),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(appearance),
|
||||
Linux: application.LinuxWindow{
|
||||
Icon: iconWindow,
|
||||
},
|
||||
|
||||
@@ -49,10 +49,34 @@ func (v ViewMode) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Theme is the preferred UI appearance: follow the OS ("system") or force
|
||||
// "light"/"dark".
|
||||
type Theme string
|
||||
|
||||
const (
|
||||
ThemeSystem Theme = "system"
|
||||
ThemeLight Theme = "light"
|
||||
ThemeDark Theme = "dark"
|
||||
)
|
||||
|
||||
// DefaultTheme applies when no file exists or its theme is empty/unknown.
|
||||
const DefaultTheme = ThemeSystem
|
||||
|
||||
var ErrUnsupportedTheme = errors.New("unsupported theme")
|
||||
|
||||
func (t Theme) IsValid() bool {
|
||||
switch t {
|
||||
case ThemeSystem, ThemeLight, ThemeDark:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UIPreferences is rewritten in full on every change; there are no partial updates.
|
||||
type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
Theme Theme `json:"theme"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
// AutostartInitialized records that the one-time autostart default
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
@@ -105,7 +129,7 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
|
||||
path: path,
|
||||
validator: validator,
|
||||
emitter: emitter,
|
||||
current: UIPreferences{ViewMode: DefaultViewMode},
|
||||
current: UIPreferences{ViewMode: DefaultViewMode, Theme: DefaultTheme},
|
||||
}
|
||||
|
||||
if err := s.load(); err != nil {
|
||||
@@ -146,6 +170,30 @@ func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTheme validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetTheme(theme Theme) error {
|
||||
if !theme.IsValid() {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedTheme, theme)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.current.Theme == theme {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.Theme = theme
|
||||
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
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged.
|
||||
func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
s.mu.Lock()
|
||||
@@ -288,6 +336,9 @@ func (s *Store) load() error {
|
||||
if !loaded.ViewMode.IsValid() {
|
||||
loaded.ViewMode = DefaultViewMode
|
||||
}
|
||||
if !loaded.Theme.IsValid() {
|
||||
loaded.Theme = DefaultTheme
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.current = loaded
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setAppAppearance points GTK at the light or dark variant of the current theme
|
||||
// so the decorations match the webview. Without it a forced Light theme keeps
|
||||
// dark decorations on a dark desktop, and the reverse.
|
||||
//
|
||||
// The theme name is switched, not just gtk-application-prefer-dark-theme:
|
||||
// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
|
||||
// which that flag cannot lighten. The flag is still set for themes that do
|
||||
// carry both variants under one name. Both are per-process settings, so this
|
||||
// changes only our own decorations; GTK re-reads the desktop value on a change,
|
||||
// which is why Theme.apply re-asserts. Must run on the main thread.
|
||||
//
|
||||
// GTK styling is app-wide, which is why this is separate from
|
||||
// setWindowAppearance: it must be applied even when no window exists yet, since
|
||||
// windows created later inherit it rather than carrying it in their options.
|
||||
func setAppAppearance(dark bool) {
|
||||
target := baseGtkTheme(gtkThemeName())
|
||||
if dark {
|
||||
if variant, ok := darkGtkVariant(target); ok {
|
||||
target = variant
|
||||
}
|
||||
}
|
||||
// An unknown name would leave GTK with no theme at all, so fall back to
|
||||
// changing nothing and let the prefer-dark flag do what it can.
|
||||
if target != "" && !gtkThemeExists(target) {
|
||||
target = ""
|
||||
}
|
||||
applyGtkTheme(target, dark)
|
||||
}
|
||||
|
||||
// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
|
||||
func baseGtkTheme(name string) string {
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
||||
return name[:len(name)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// darkGtkVariant reports the installed dark counterpart of a base theme name.
|
||||
// Themes that carry both variants under one name have none, and rely on
|
||||
// gtk-application-prefer-dark-theme instead.
|
||||
func darkGtkVariant(base string) (string, bool) {
|
||||
if base == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if candidate := base + suffix; gtkThemeExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gtkThemeExists reports whether a theme of that name is installed, searching
|
||||
// the same locations GTK does.
|
||||
func gtkThemeExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, dir := range gtkThemeDirs() {
|
||||
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gtkThemeDirs() []string {
|
||||
var dirs []string
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".themes"))
|
||||
}
|
||||
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||
dirs = append(dirs, filepath.Join(dataHome, "themes"))
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
|
||||
}
|
||||
dataDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if dataDirs == "" {
|
||||
dataDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(dataDirs, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, filepath.Join(dir, "themes"))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
// The GTK major version is the only difference between the two Linux builds, so
|
||||
// it is selected by these two directives rather than by keeping a second copy of
|
||||
// this file per version: the C below and the Go wrappers under it are identical
|
||||
// for GTK3 and GTK4, and both resolve <gtk/gtk.h> through pkg-config.
|
||||
#cgo gtk3 pkg-config: gtk+-3.0
|
||||
#cgo !gtk3 pkg-config: gtk4
|
||||
#include <stdlib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
static char *nbGetGtkThemeName(void) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
char *name = NULL;
|
||||
g_object_get(settings, "gtk-theme-name", &name, NULL);
|
||||
return name;
|
||||
}
|
||||
|
||||
// name may be NULL to leave the theme name untouched.
|
||||
static void nbSetGtkTheme(const char *name, int dark) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return;
|
||||
}
|
||||
if (name != NULL && name[0] != '\0') {
|
||||
g_object_set(settings, "gtk-theme-name", name, NULL);
|
||||
}
|
||||
g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
|
||||
}
|
||||
|
||||
static void nbFreeGtkString(char *s) { g_free(s); }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func gtkThemeName() string {
|
||||
c := C.nbGetGtkThemeName()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
defer C.nbFreeGtkString(c)
|
||||
return C.GoString(c)
|
||||
}
|
||||
|
||||
func applyGtkTheme(name string, dark bool) {
|
||||
var cName *C.char
|
||||
if name != "" {
|
||||
cName = C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
}
|
||||
var forced C.int
|
||||
if dark {
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetGtkTheme(cName, forced)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(linux && cgo)
|
||||
|
||||
package services
|
||||
|
||||
// setAppAppearance is a no-op where the platform has no app-wide appearance to
|
||||
// set; macOS and Windows theme each window instead, via setWindowAppearance.
|
||||
func setAppAppearance(bool) {}
|
||||
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
|
||||
return s.store.SetTheme(theme)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
|
||||
// The frontend resolves the "system" preference against it.
|
||||
const EventSystemThemeChanged = "netbird:system-theme:changed"
|
||||
|
||||
// SystemTheme is the EventSystemThemeChanged payload.
|
||||
type SystemTheme struct {
|
||||
Dark bool `json:"dark"`
|
||||
}
|
||||
|
||||
// Theme keeps native window background colours in step with the persisted
|
||||
// theme preference so no window flashes the wrong surface before the webview
|
||||
// paints. The frontend applies the matching .dark class via ThemeContext.
|
||||
type Theme struct {
|
||||
app *application.App
|
||||
store *preferences.Store
|
||||
// mu serializes apply: concurrent callers could otherwise enqueue a stale
|
||||
// pref's native updates after a newer one's.
|
||||
mu sync.Mutex
|
||||
// started gates the main-thread dispatch in apply: Run installs the platform
|
||||
// layer InvokeAsync needs, and the store subscription can fire before that.
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
// NewTheme wires the store subscription and OS theme-change listener. Call
|
||||
// before any window is created so creation-time colours are already themed.
|
||||
func NewTheme(app *application.App, store *preferences.Store) *Theme {
|
||||
t := &Theme{app: app, store: store}
|
||||
pref := store.Get().Theme
|
||||
setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
|
||||
|
||||
// Window creation resolves through this rather than the seed above, which
|
||||
// is wrong until Run installs the platform layer: Env.IsDarkMode reports
|
||||
// light before that, so a "system" launch on a dark OS would build the
|
||||
// first window light. The ApplicationStarted apply below cannot be relied
|
||||
// on to land first because Wails runs each listener in its own goroutine.
|
||||
// One store read backs both fields, so the snapshot is always self-consistent.
|
||||
setAppearanceResolver(func() Appearance {
|
||||
p := t.store.Get().Theme
|
||||
return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
|
||||
})
|
||||
|
||||
ch, _ := store.Subscribe()
|
||||
go func() {
|
||||
var last preferences.Theme
|
||||
for p := range ch {
|
||||
if p.Theme == last {
|
||||
continue
|
||||
}
|
||||
last = p.Theme
|
||||
t.apply()
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
|
||||
// process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
|
||||
// re-asserted or the native chrome drifts to the OS appearance. The event's
|
||||
// own IsDarkMode is deliberately unused: Wails runs each application event
|
||||
// handler in its own goroutine, so two rapid flips race, and apply re-reads
|
||||
// the appearance under mu instead.
|
||||
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
// Startup is split in two because Wails runs every application-event
|
||||
// listener in its own goroutine, so a listener cannot be ordered against the
|
||||
// one that opens the first-launch window. Hooks can: they run sequentially,
|
||||
// in registration order, and all of them before any listener is spawned.
|
||||
//
|
||||
// The app-wide GTK theme goes in the hook because it is the part a window
|
||||
// must not be created without. On Linux it draws the decorations and
|
||||
// application.LinuxWindow carries no theme of its own, so a window built
|
||||
// before it lands shows OS-coloured decorations until it does. It is applied
|
||||
// synchronously for the same reason -- returning from the hook has to mean
|
||||
// the theme is live. This relies on the listener below existing: Wails skips
|
||||
// an event's hooks entirely when it has no listeners.
|
||||
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.started.Store(true)
|
||||
t.syncAppAppearance()
|
||||
})
|
||||
|
||||
// The rest of the startup apply. Env.IsDarkMode is a stub until the platform
|
||||
// layer is up, so re-resolve once the app has started or a "system" launch on
|
||||
// a light OS stays seeded dark.
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// syncAppAppearance applies the app-wide appearance and waits for the UI thread
|
||||
// to have done it. Use it where a window is about to be created and must not be
|
||||
// built against the OS appearance: apply dispatches its own native work
|
||||
// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
|
||||
// land after the window exists.
|
||||
//
|
||||
// No-op before the app has started, where InvokeSync has no platform layer to
|
||||
// dispatch to. Reads the appearance under mu like apply, so the two cannot
|
||||
// interleave into a torn update.
|
||||
func (t *Theme) syncAppAppearance() {
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
dark := resolveDark(pref, t.app.Env.IsDarkMode())
|
||||
setAppearance(pref, dark)
|
||||
application.InvokeSync(func() { setAppAppearance(dark) })
|
||||
}
|
||||
|
||||
// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
|
||||
// the "system" preference from the same source as the native layer.
|
||||
func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
|
||||
return t.app.Env.IsDarkMode(), nil
|
||||
}
|
||||
|
||||
// resolveDark maps a preference to an effective appearance against a system
|
||||
// reading the caller already took.
|
||||
func resolveDark(pref preferences.Theme, systemDark bool) bool {
|
||||
switch pref {
|
||||
case preferences.ThemeDark:
|
||||
return true
|
||||
case preferences.ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return systemDark
|
||||
}
|
||||
}
|
||||
|
||||
// apply recomputes the effective appearance, re-tints every live window
|
||||
// (including the macOS NSWindow appearance so the frame matches the webview)
|
||||
// and publishes the system appearance the frontend resolves "system" against.
|
||||
//
|
||||
// Everything runs under mu and reads the appearance here rather than taking it
|
||||
// from a caller, so a later apply always carries the fresher state and the
|
||||
// frontend event is ordered by the same lock as the native assignments. Emit
|
||||
// only appends to a FIFO mailbox, so holding mu across it cannot block.
|
||||
//
|
||||
// The OS is read exactly once per update and the resolved value is passed on to
|
||||
// the background and the native chrome, so those cannot land on either side of
|
||||
// an OS flip that happens mid-apply. The event carries the raw system reading,
|
||||
// not the resolved one, because the frontend resolves "system" itself.
|
||||
func (t *Theme) apply() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
systemDark := t.app.Env.IsDarkMode()
|
||||
dark := resolveDark(pref, systemDark)
|
||||
setAppearance(pref, dark)
|
||||
t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
|
||||
|
||||
// Before Run there is no platform layer for InvokeAsync to dispatch to.
|
||||
// Windows created later read the globals set above.
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
colour := windowBackgroundColour(dark)
|
||||
// Re-tint on the UI thread and resolve each native handle there. Window
|
||||
// teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
|
||||
// a window closed meanwhile is either gone from GetAll or yields a nil
|
||||
// handle -- never a freed handle the OS may already have reused.
|
||||
application.InvokeAsync(func() {
|
||||
// App-wide first, and unconditionally: on Linux this is the GTK theme
|
||||
// that draws the decorations, and it must be set even with no window
|
||||
// open because later windows inherit it instead of carrying it.
|
||||
setAppAppearance(dark)
|
||||
for _, w := range t.app.Window.GetAll() {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
w.SetBackgroundColour(colour)
|
||||
setWindowAppearance(w.NativeWindow(), pref, dark)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
|
||||
//
|
||||
// Assigns directly rather than dispatching: Theme.apply already runs this on
|
||||
// the main thread. Deferring would outlive the caller's check that the window
|
||||
// is alive, and the __bridge cast does not retain it, so the block could touch
|
||||
// a freed NSWindow.
|
||||
static void nbSetWindowAppearance(void *nsWindow, int forced) {
|
||||
NSWindow *window = (__bridge NSWindow *)nsWindow;
|
||||
if (forced < 0) {
|
||||
window.appearance = nil;
|
||||
} else {
|
||||
NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
|
||||
window.appearance = [NSAppearance appearanceNamed:name];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance pins the NSWindow appearance to the forced theme, or
|
||||
// hands it back to the OS for ThemeSystem. Without this, a window created
|
||||
// under one OS appearance keeps its dark/light frame after a manual theme
|
||||
// flip, leaving a mismatched border around the webview. Must run on the main
|
||||
// thread, which Theme.apply guarantees.
|
||||
//
|
||||
// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
|
||||
// AppKit track the OS itself, which cannot drift from a snapshot we took.
|
||||
func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
|
||||
if nsWindow == nil {
|
||||
return
|
||||
}
|
||||
forced := C.int(-1)
|
||||
switch pref {
|
||||
case preferences.ThemeLight:
|
||||
forced = 0
|
||||
case preferences.ThemeDark:
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetWindowAppearance(nsWindow, forced)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin && !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance is a no-op wherever there is no per-window appearance to
|
||||
// set, which is every target this file covers. On Linux the appearance is real
|
||||
// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
|
||||
// targets there is no native theming to apply at all and setAppAppearance is
|
||||
// itself a stub (appappearance_other.go).
|
||||
func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user