This commit is contained in:
Zoltán Papp
2026-03-02 14:12:53 +01:00
parent 9a6a72e88e
commit 04a982263d
89 changed files with 8147 additions and 22 deletions

3
client/uiwails/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
frontend/node_modules/
frontend/dist/
bin/

View File

@@ -0,0 +1 @@
4340708917a9379498dcca2d1bd8c665

View File

@@ -0,0 +1 @@
f88f16cee21f42c24ff6b3c1410b0ddd

View File

@@ -0,0 +1,32 @@
version: '3'
includes:
common: ./build/Taskfile.yml
linux: ./build/linux/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
vars:
APP_NAME: "netbird-ui"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,61 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
label: build:frontend (DEV={{.DEV}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
- exclude: node_modules/**/*
generates:
- dist/**/*
deps:
- task: install:frontend:deps
cmds:
- npm run {{.BUILD_COMMAND}} -q
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icon.ico"
cmds:
- echo "Icon generation skipped (no appicon.png)"
status:
- test ! -f appicon.png
dev:frontend:
summary: Runs the frontend in development mode
dir: frontend
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Build script for NetBird Wails v3 on Linux
set -e
echo "Installing system dependencies for Wails v3 on Linux..."
sudo apt-get update
sudo apt-get install -y \
libayatana-appindicator3-dev \
gcc \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libglib2.0-dev \
libsoup-3.0-dev \
libx11-dev \
npm
echo "Installing wails3 CLI..."
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.72
echo "Building fancyui..."
cd "$(dirname "$0")/.."
wails3 build
echo "Build complete."

View File

@@ -0,0 +1,35 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Linux
cmds:
- task: build:native
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View File

@@ -0,0 +1,217 @@
//go:build !(linux && 386)
package event
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)
// NotifyFunc is a callback used to send desktop notifications.
type NotifyFunc func(title, body string)
// Handler is a callback invoked for each received daemon event.
type Handler func(*proto.SystemEvent)
// Manager subscribes to daemon events and dispatches them.
type Manager struct {
addr string
notify NotifyFunc
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
enabled bool
handlers []Handler
connMu sync.Mutex
conn *grpc.ClientConn
client proto.DaemonServiceClient
}
// NewManager creates a new event Manager.
func NewManager(addr string, notify NotifyFunc) *Manager {
return &Manager{
addr: addr,
notify: notify,
}
}
// Start begins event streaming with exponential backoff reconnection.
func (m *Manager) Start(ctx context.Context) {
m.mu.Lock()
m.ctx, m.cancel = context.WithCancel(ctx)
m.mu.Unlock()
expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{
InitialInterval: time.Second,
RandomizationFactor: backoff.DefaultRandomizationFactor,
Multiplier: backoff.DefaultMultiplier,
MaxInterval: 10 * time.Second,
MaxElapsedTime: 0,
Stop: backoff.Stop,
Clock: backoff.SystemClock,
}, ctx)
if err := backoff.Retry(m.streamEvents, expBackOff); err != nil {
log.Errorf("event stream ended: %v", err)
}
}
func (m *Manager) streamEvents() error {
m.mu.Lock()
ctx := m.ctx
m.mu.Unlock()
client, err := m.getClient()
if err != nil {
return fmt.Errorf("create client: %w", err)
}
stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{})
if err != nil {
return fmt.Errorf("subscribe events: %w", err)
}
log.Info("subscribed to daemon events")
defer log.Info("unsubscribed from daemon events")
for {
event, err := stream.Recv()
if err != nil {
return fmt.Errorf("receive event: %w", err)
}
m.handleEvent(event)
}
}
// Stop cancels the event stream and closes the connection.
func (m *Manager) Stop() {
m.mu.Lock()
if m.cancel != nil {
m.cancel()
}
m.mu.Unlock()
m.connMu.Lock()
if m.conn != nil {
m.conn.Close()
m.conn = nil
m.client = nil
}
m.connMu.Unlock()
}
// SetNotificationsEnabled enables or disables desktop notifications.
func (m *Manager) SetNotificationsEnabled(enabled bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.enabled = enabled
}
// AddHandler registers an event handler.
func (m *Manager) AddHandler(h Handler) {
m.mu.Lock()
defer m.mu.Unlock()
m.handlers = append(m.handlers, h)
}
func (m *Manager) handleEvent(event *proto.SystemEvent) {
m.mu.Lock()
enabled := m.enabled
handlers := slices.Clone(m.handlers)
m.mu.Unlock()
// Critical events are always shown.
if !enabled && event.Severity != proto.SystemEvent_CRITICAL {
goto dispatch
}
if event.UserMessage != "" && m.notify != nil {
title := getEventTitle(event)
body := event.UserMessage
if id := event.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
m.notify(title, body)
}
dispatch:
for _, h := range handlers {
go h(event)
}
}
func getEventTitle(event *proto.SystemEvent) string {
var prefix string
switch event.Severity {
case proto.SystemEvent_CRITICAL:
prefix = "Critical"
case proto.SystemEvent_ERROR:
prefix = "Error"
case proto.SystemEvent_WARNING:
prefix = "Warning"
default:
prefix = "Info"
}
var category string
switch event.Category {
case proto.SystemEvent_DNS:
category = "DNS"
case proto.SystemEvent_NETWORK:
category = "Network"
case proto.SystemEvent_AUTHENTICATION:
category = "Authentication"
case proto.SystemEvent_CONNECTIVITY:
category = "Connectivity"
default:
category = "System"
}
return fmt.Sprintf("%s: %s", prefix, category)
}
// getClient returns a cached gRPC client, creating the connection on first use.
func (m *Manager) getClient() (proto.DaemonServiceClient, error) {
m.connMu.Lock()
defer m.connMu.Unlock()
if m.client != nil {
return m.client, nil
}
target := m.addr
if strings.HasPrefix(target, "tcp://") {
target = strings.TrimPrefix(target, "tcp://")
} else if strings.HasPrefix(target, "unix://") {
target = "unix:" + strings.TrimPrefix(target, "unix://")
}
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUserAgent("netbird-fancyui/"+version.NetbirdVersion()),
)
if err != nil {
return nil, err
}
m.conn = conn
m.client = proto.NewDaemonServiceClient(conn)
log.Debugf("event manager: gRPC connection established to %s", m.addr)
return m.client, nil
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetBird</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2502
client/uiwails/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "netbird-fancyui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"typescript": "^5.6.3",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,53 @@
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { Events } from '@wailsio/runtime'
import Status from './pages/Status'
import Settings from './pages/Settings'
import Networks from './pages/Networks'
import Profiles from './pages/Profiles'
import Peers from './pages/Peers'
import Debug from './pages/Debug'
import Update from './pages/Update'
import NavBar from './components/NavBar'
/**
* Navigator listens for the "navigate" event emitted by the Go backend
* and programmatically navigates the React router.
*/
function Navigator() {
const navigate = useNavigate()
useEffect(() => {
const unsub = Events.On('navigate', (event: { data: string[] }) => {
const path = event.data[0]
if (path) navigate(path)
})
return () => {
if (typeof unsub === 'function') unsub()
}
}, [navigate])
return null
}
export default function App() {
return (
<HashRouter>
<Navigator />
<div className="min-h-screen bg-nb-gray-DEFAULT text-nb-gray-100 flex">
<NavBar />
<main className="flex-1 p-6 overflow-y-auto h-screen">
<Routes>
<Route path="/" element={<Status />} />
<Route path="/settings" element={<Settings />} />
<Route path="/peers" element={<Peers />} />
<Route path="/networks" element={<Networks />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/debug" element={<Debug />} />
<Route path="/update" element={<Update />} />
</Routes>
</main>
</div>
</HashRouter>
)
}

View File

@@ -0,0 +1,126 @@
/**
* Type definitions for the auto-generated Wails v3 service bindings.
* Run `wails3 generate bindings` to regenerate the actual TypeScript bindings
* from the Go service methods. These types mirror the Go structs.
*
* The actual binding files will be generated into frontend/bindings/ by the
* Wails CLI. This file serves as a centralized re-export and type reference.
*/
// ---- Connection service ----
export interface StatusInfo {
status: string
ip: string
publicKey: string
fqdn: string
connectedPeers: number
}
// ---- Settings service ----
export interface ConfigInfo {
managementUrl: string
adminUrl: string
preSharedKey: string
interfaceName: string
wireguardPort: number
disableAutoConnect: boolean
serverSshAllowed: boolean
rosenpassEnabled: boolean
rosenpassPermissive: boolean
lazyConnectionEnabled: boolean
blockInbound: boolean
disableNotifications: boolean
}
// ---- Network service ----
export interface NetworkInfo {
id: string
range: string
domains: string[]
selected: boolean
resolvedIPs: Record<string, string[]>
}
// ---- Profile service ----
export interface ProfileInfo {
name: string
isActive: boolean
}
export interface ActiveProfileInfo {
profileName: string
username: string
email: string
}
// ---- Debug service ----
export interface DebugBundleParams {
anonymize: boolean
systemInfo: boolean
upload: boolean
uploadUrl: string
runDurationMins: number
enablePersistence: boolean
}
export interface DebugBundleResult {
localPath: string
uploadedKey: string
uploadFailureReason: string
}
// ---- Peers service ----
export interface PeerInfo {
ip: string
pubKey: string
fqdn: string
connStatus: string
connStatusUpdate: string
relayed: boolean
relayAddress: string
latencyMs: number
bytesRx: number
bytesTx: number
rosenpassEnabled: boolean
networks: string[]
lastHandshake: string
localIceType: string
remoteIceType: string
localEndpoint: string
remoteEndpoint: string
}
// ---- Update service ----
export interface InstallerResult {
success: boolean
errorMsg: string
}
/**
* Wails v3 service call helper.
* After running `wails3 generate bindings`, use the generated functions directly.
* This helper wraps window.__wails.call for manual use during development.
*/
export async function call<T>(service: string, method: string, ...args: unknown[]): Promise<T> {
// This will be replaced by generated bindings after `wails3 generate bindings`
// For now, call via the Wails runtime bridge
const w = window as typeof window & {
go?: {
[svc: string]: {
[method: string]: (...args: unknown[]) => Promise<T>
}
}
}
const svc = w.go?.[service]
if (!svc) throw new Error(`Service ${service} not found. Run wails3 generate bindings.`)
const fn = svc[method]
if (!fn) throw new Error(`Method ${service}.${method} not found.`)
return fn(...args)
}

View File

@@ -0,0 +1,132 @@
import { NavLink } from 'react-router-dom'
import NetBirdLogo from './NetBirdLogo'
const navItems = [
{ to: '/', label: 'Status', icon: StatusIcon },
{ to: '/peers', label: 'Peers', icon: PeersIcon },
{ to: '/networks', label: 'Networks', icon: NetworksIcon },
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
{ to: '/debug', label: 'Debug', icon: DebugIcon },
{ to: '/update', label: 'Update', icon: UpdateIcon },
]
export default function NavBar() {
return (
<nav className="w-[15rem] min-w-[15rem] bg-nb-gray-950 border-r border-nb-gray-900 flex flex-col h-screen">
{/* Logo */}
<div className="px-5 py-5 border-b border-nb-gray-900">
<NetBirdLogo full />
</div>
{/* Nav items */}
<div className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-[.87rem] font-normal transition-colors ${
isActive
? 'bg-nb-gray-900 text-white'
: 'text-nb-gray-400 hover:text-white hover:bg-nb-gray-900/50'
}`
}
>
{({ isActive }) => (
<>
<item.icon active={isActive} />
<span>{item.label}</span>
</>
)}
</NavLink>
))}
</div>
{/* Version footer */}
<div className="px-5 py-3 border-t border-nb-gray-900 text-xs text-nb-gray-500">
NetBird Client
</div>
</nav>
)
}
function StatusIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
)
}
function PeersIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function NetworksIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="19" r="2" />
<line x1="12" y1="7" x2="5" y2="17" />
<line x1="12" y1="7" x2="19" y2="17" />
</svg>
)
}
function ProfilesIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
}
function SettingsIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function DebugIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
)
}
function UpdateIcon({ active }: { active: boolean }) {
return (
<svg className={`w-4 h-4 shrink-0 ${active ? 'text-netbird' : 'text-nb-gray-400'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
)
}

View File

@@ -0,0 +1,20 @@
function BirdMark({ className }: { className?: string }) {
return (
<svg className={className} width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
</svg>
)
}
export default function NetBirdLogo({ full = false, className }: { full?: boolean; className?: string }) {
if (!full) return <BirdMark className={className} />
return (
<div className={`flex items-center gap-2 ${className ?? ''}`}>
<BirdMark />
<span className="text-lg font-bold tracking-wide text-nb-gray-100">NETBIRD</span>
</div>
)
}

View File

@@ -0,0 +1,113 @@
@import "tailwindcss";
@theme {
--color-netbird-50: #fff6ed;
--color-netbird-100: #feecd6;
--color-netbird-150: #ffdfb8;
--color-netbird-200: #ffd4a6;
--color-netbird-300: #fab677;
--color-netbird-400: #f68330;
--color-netbird-DEFAULT: #f68330;
--color-netbird-500: #f46d1b;
--color-netbird-600: #e55311;
--color-netbird-700: #be3e10;
--color-netbird-800: #973215;
--color-netbird-900: #7a2b14;
--color-netbird-950: #421308;
--color-nb-gray-DEFAULT: #181a1d;
--color-nb-gray-50: #f4f6f7;
--color-nb-gray-100: #e4e7e9;
--color-nb-gray-200: #cbd2d6;
--color-nb-gray-300: #a3adb5;
--color-nb-gray-400: #7c8994;
--color-nb-gray-500: #616e79;
--color-nb-gray-600: #535d67;
--color-nb-gray-700: #474e57;
--color-nb-gray-800: #3f444b;
--color-nb-gray-900: #2e3238;
--color-nb-gray-910: #292c31;
--color-nb-gray-920: #25282d;
--color-nb-gray-930: #212428;
--color-nb-gray-940: #1c1e21;
--color-nb-gray-950: #181a1d;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
background-color: #181a1d;
color: #e4e7e9;
}
#root {
min-height: 100vh;
}
/* Toggle switch — matches dashboard ToggleSwitch.tsx */
.toggle-track {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: 44px;
height: 24px;
border-radius: 9999px;
border: 2px solid transparent;
cursor: pointer;
transition: background-color 0.2s;
background-color: var(--color-nb-gray-700);
}
.toggle-track[aria-checked="true"] {
background-color: var(--color-netbird-400);
}
.toggle-thumb {
pointer-events: none;
display: block;
width: 20px;
height: 20px;
border-radius: 9999px;
background-color: white;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
transition: transform 0.2s;
transform: translateX(0);
}
.toggle-track[aria-checked="true"] .toggle-thumb {
transform: translateX(20px);
}
/* Small toggle (matches dashboard ToggleSwitch size=small) */
.toggle-track-sm {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: 36px;
height: 18px;
border-radius: 9999px;
border: 2px solid transparent;
cursor: pointer;
transition: background-color 0.2s;
background-color: var(--color-nb-gray-700);
}
.toggle-track-sm[aria-checked="true"] {
background-color: var(--color-netbird-400);
}
.toggle-thumb-sm {
pointer-events: none;
display: block;
width: 14px;
height: 14px;
border-radius: 9999px;
background-color: white;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
transition: transform 0.2s;
transform: translateX(0);
}
.toggle-track-sm[aria-checked="true"] .toggle-thumb-sm {
transform: translateX(17px);
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,169 @@
import { useState } from 'react'
import { Call } from '@wailsio/runtime'
import type { DebugBundleParams, DebugBundleResult } from '../bindings'
const DEFAULT_UPLOAD_URL = 'https://upload.netbird.io'
export default function Debug() {
const [anonymize, setAnonymize] = useState(false)
const [systemInfo, setSystemInfo] = useState(true)
const [upload, setUpload] = useState(true)
const [uploadUrl, setUploadUrl] = useState(DEFAULT_UPLOAD_URL)
const [runForDuration, setRunForDuration] = useState(true)
const [durationMins, setDurationMins] = useState(1)
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState('')
const [result, setResult] = useState<DebugBundleResult | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleCreate() {
if (upload && !uploadUrl) {
setError('Upload URL is required when upload is enabled')
return
}
setRunning(true)
setError(null)
setResult(null)
setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)…` : 'Creating debug bundle…')
const params: DebugBundleParams = {
anonymize,
systemInfo,
upload,
uploadUrl: upload ? uploadUrl : '',
runDurationMins: runForDuration ? durationMins : 0,
enablePersistence: true,
}
try {
console.log('[Debug] calling services.DebugService.CreateDebugBundle')
const res = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.DebugService.CreateDebugBundle', params) as DebugBundleResult
console.log('[Debug] CreateDebugBundle result:', JSON.stringify(res))
if (res) {
setResult(res)
setProgress('Bundle created successfully')
}
} catch (e) {
console.error('[Debug] CreateDebugBundle error:', e)
setError(String(e))
setProgress('')
} finally {
setRunning(false)
}
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-2">Debug</h1>
<p className="text-nb-gray-400 text-sm mb-6">
Create a debug bundle to help troubleshoot issues with NetBird.
</p>
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
<Toggle label="Anonymize sensitive information (IPs, domains…)" checked={anonymize} onChange={setAnonymize} />
<Toggle label="Include system information (routes, interfaces…)" checked={systemInfo} onChange={setSystemInfo} />
<Toggle label="Upload bundle automatically after creation" checked={upload} onChange={setUpload} />
{upload && (
<div>
<label className="block text-sm text-nb-gray-400 mb-1.5">Upload URL</label>
<input
className="w-full px-3 py-2 bg-nb-gray-900 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
value={uploadUrl}
onChange={e => setUploadUrl(e.target.value)}
disabled={running}
/>
</div>
)}
<div className="border-t border-nb-gray-900 pt-4">
<Toggle
label="Run with trace logs before creating bundle"
checked={runForDuration}
onChange={setRunForDuration}
/>
{runForDuration && (
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-nb-gray-400">for</span>
<input
type="number"
min={1}
max={60}
value={durationMins}
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
disabled={running}
className="w-16 px-2 py-1 bg-nb-gray-900 border border-nb-gray-800 rounded text-sm text-center focus:outline-none focus:border-netbird"
/>
<span className="text-sm text-nb-gray-400">{durationMins === 1 ? 'minute' : 'minutes'}</span>
</div>
)}
{runForDuration && (
<p className="text-xs text-nb-gray-500 mt-2">
Note: NetBird will be brought up and down during collection.
</p>
)}
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
{progress && (
<div className="mt-4 p-3 bg-nb-gray-920 border border-nb-gray-900 rounded-lg text-sm">
<span className={running ? 'animate-pulse text-yellow-300' : 'text-green-400'}>{progress}</span>
</div>
)}
{result && (
<div className="mt-4 bg-nb-gray-920 rounded-xl p-5 border border-nb-gray-900 text-sm space-y-2">
{result.uploadedKey ? (
<>
<p className="text-green-400 font-medium">Bundle uploaded successfully!</p>
<div className="flex items-center gap-2">
<span className="text-nb-gray-400">Upload key:</span>
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono">{result.uploadedKey}</code>
</div>
</>
) : result.uploadFailureReason ? (
<p className="text-yellow-400">Upload failed: {result.uploadFailureReason}</p>
) : null}
<div className="flex items-center gap-2">
<span className="text-nb-gray-400">Local path:</span>
<code className="bg-nb-gray-900 px-2 py-0.5 rounded text-xs font-mono break-all">{result.localPath}</code>
</div>
</div>
)}
<div className="mt-4">
<button
onClick={handleCreate}
disabled={running}
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
>
{running ? 'Running…' : 'Create Debug Bundle'}
</button>
</div>
</div>
)
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-3 cursor-pointer">
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="toggle-track"
>
<span className="toggle-thumb" />
</button>
<span className="text-sm">{label}</span>
</label>
)
}

View File

@@ -0,0 +1,356 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime'
import type { NetworkInfo } from '../bindings'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService'
type Tab = 'all' | 'overlapping' | 'exit-node'
type SortKey = 'id' | 'range'
type SortDir = 'asc' | 'desc'
export default function Networks() {
const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [tab, setTab] = useState<Tab>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('id')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
let method: string
if (tab === 'all') method = 'ListNetworks'
else if (tab === 'overlapping') method = 'ListOverlappingNetworks'
else method = 'ListExitNodes'
const data = await Call.ByName(`${SVC}.${method}`) as NetworkInfo[]
setNetworks(data ?? [])
} catch (e) {
console.error('[Networks] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [tab])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const filtered = useMemo(() => {
let list = networks
if (search) {
const q = search.toLowerCase()
list = list.filter(n =>
n.id.toLowerCase().includes(q) ||
n.range?.toLowerCase().includes(q) ||
n.domains?.some(d => d.toLowerCase().includes(q))
)
}
return [...list].sort((a, b) => {
const aVal = sortKey === 'id' ? a.id : (a.range ?? '')
const bVal = sortKey === 'id' ? b.id : (b.range ?? '')
const cmp = aVal.localeCompare(bVal)
return sortDir === 'asc' ? cmp : -cmp
})
}, [networks, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
async function toggle(id: string, selected: boolean) {
try {
if (selected) await Call.ByName(`${SVC}.DeselectNetwork`, id)
else await Call.ByName(`${SVC}.SelectNetwork`, id)
await load()
} catch (e) {
setError(String(e))
}
}
async function selectAll() {
try {
await Call.ByName(`${SVC}.SelectAllNetworks`)
await load()
} catch (e) { setError(String(e)) }
}
async function deselectAll() {
try {
await Call.ByName(`${SVC}.DeselectAllNetworks`)
await load()
} catch (e) { setError(String(e)) }
}
const selectedCount = networks.filter(n => n.selected).length
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Networks</h1>
{/* Tabs */}
<div className="flex gap-1 mb-5 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900 max-w-md">
{([['all', 'All Networks'], ['overlapping', 'Overlapping'], ['exit-node', 'Exit Nodes']] as [Tab, string][]).map(([t, label]) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-1.5 rounded text-xs font-medium transition-colors ${
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{/* Toolbar: search + actions */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" />
</svg>
<input
className="w-full pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
placeholder="Search by name, range or domain..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2 ml-auto">
<ActionButton onClick={selectAll}>Select All</ActionButton>
<ActionButton onClick={deselectAll}>Deselect All</ActionButton>
<ActionButton onClick={load}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</ActionButton>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
{error}
</div>
)}
{/* Selection summary */}
{selectedCount > 0 && (
<div className="mb-3 text-xs text-nb-gray-400">
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
</div>
)}
{/* Table */}
{loading && networks.length === 0 ? (
<TableSkeleton />
) : filtered.length === 0 && networks.length === 0 ? (
<EmptyState tab={tab} />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-nb-gray-400 text-sm">
No networks match your search.
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
</div>
) : (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
<SortableHeader label="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Resolved IPs</th>
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide w-20">Active</th>
</tr>
</thead>
<tbody>
{filtered.map(n => (
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
))}
</tbody>
</table>
{/* Pagination footer */}
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
</div>
</div>
)}
</div>
)
}
/* ---- Row ---- */
function NetworkRow({ network, onToggle }: { network: NetworkInfo; onToggle: () => void }) {
const domains = network.domains ?? []
const resolvedEntries = Object.entries(network.resolvedIPs ?? {})
const hasDomains = domains.length > 0
return (
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
{/* Network name cell (dashboard-style icon square + name) */}
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-3 min-w-[180px]">
<NetworkSquare name={network.id} active={network.selected} />
<div className="flex flex-col">
<span className="font-medium text-sm text-nb-gray-100">{network.id}</span>
{hasDomains && domains.length > 1 && (
<span className="text-xs text-nb-gray-500 mt-0.5">{domains.length} domains</span>
)}
</div>
</div>
</td>
{/* Range / Domains */}
<td className="px-4 py-3 align-middle">
{hasDomains ? (
<div className="flex flex-col gap-1">
{domains.slice(0, 2).map(d => (
<span key={d} className="font-mono text-[0.82rem] text-nb-gray-300">{d}</span>
))}
{domains.length > 2 && (
<span className="text-xs text-nb-gray-500" title={domains.join(', ')}>+{domains.length - 2} more</span>
)}
</div>
) : (
<span className="font-mono text-[0.82rem] text-nb-gray-300">{network.range}</span>
)}
</td>
{/* Resolved IPs */}
<td className="px-4 py-3 align-middle">
{resolvedEntries.length > 0 ? (
<div className="flex flex-col gap-1">
{resolvedEntries.slice(0, 2).map(([domain, ips]) => (
<span key={domain} className="font-mono text-xs text-nb-gray-400" title={`${domain}: ${ips.join(', ')}`}>
{ips[0]}{ips.length > 1 && <span className="text-nb-gray-600"> +{ips.length - 1}</span>}
</span>
))}
{resolvedEntries.length > 2 && (
<span className="text-xs text-nb-gray-600">+{resolvedEntries.length - 2} more</span>
)}
</div>
) : (
<span className="text-nb-gray-600"></span>
)}
</td>
{/* Active toggle */}
<td className="px-4 py-3 align-middle">
<button
role="switch"
aria-checked={network.selected}
onClick={onToggle}
className="toggle-track-sm"
>
<span className="toggle-thumb-sm" />
</button>
</td>
</tr>
)
}
/* ---- Network Icon Square (matches dashboard NetworkInformationSquare) ---- */
function NetworkSquare({ name, active }: { name: string; active: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div className="relative h-10 w-10 shrink-0 rounded-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
{initials}
{/* Status dot */}
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${active ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
{/* Corner mask for rounded dot cutout */}
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
</div>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<th
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
onClick={() => onSort(sortKey)}
>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</th>
)
}
/* ---- Action Button ---- */
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
>
{children}
</button>
)
}
/* ---- Empty State ---- */
function EmptyState({ tab }: { tab: Tab }) {
const msg = tab === 'exit-node'
? 'No exit nodes configured.'
: tab === 'overlapping'
? 'No overlapping networks detected.'
: 'No networks found.'
return (
<div className="rounded-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.777.514-3.434 1.4-4.832" />
</svg>
</div>
<p className="text-sm text-nb-gray-400">{msg}</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
<div className="bg-nb-gray-900 h-11" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
<div className="w-4 h-4 rounded bg-nb-gray-800" />
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
<div className="h-4 w-24 rounded bg-nb-gray-800" />
</div>
<div className="h-4 w-32 rounded bg-nb-gray-800" />
<div className="h-4 w-20 rounded bg-nb-gray-800" />
<div className="h-6 w-16 rounded-md bg-nb-gray-800" />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,354 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime'
import type { PeerInfo } from '../bindings'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService'
type SortKey = 'fqdn' | 'ip' | 'status' | 'latency'
type SortDir = 'asc' | 'desc'
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatLatency(ms: number): string {
if (ms <= 0) return '—'
if (ms < 1) return '<1 ms'
return `${ms.toFixed(1)} ms`
}
function peerName(p: PeerInfo): string {
if (p.fqdn) return p.fqdn.replace(/\.netbird\.cloud\.?$/, '')
return p.ip || p.pubKey.substring(0, 8)
}
export default function Peers() {
const [peers, setPeers] = useState<PeerInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('fqdn')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await Call.ByName(`${SVC}.GetPeers`) as PeerInfo[]
setPeers(data ?? [])
} catch (e) {
console.error('[Peers] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const connectedCount = useMemo(() => peers.filter(p => p.connStatus === 'Connected').length, [peers])
const filtered = useMemo(() => {
let list = peers
if (search) {
const q = search.toLowerCase()
list = list.filter(p =>
peerName(p).toLowerCase().includes(q) ||
p.ip?.toLowerCase().includes(q) ||
p.connStatus?.toLowerCase().includes(q) ||
p.fqdn?.toLowerCase().includes(q)
)
}
return [...list].sort((a, b) => {
let cmp = 0
switch (sortKey) {
case 'fqdn': cmp = peerName(a).localeCompare(peerName(b)); break
case 'ip': cmp = (a.ip ?? '').localeCompare(b.ip ?? ''); break
case 'status': cmp = (a.connStatus ?? '').localeCompare(b.connStatus ?? ''); break
case 'latency': cmp = (a.latencyMs ?? 0) - (b.latencyMs ?? 0); break
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [peers, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Peers</h1>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-nb-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx={11} cy={11} r={8} /><path d="m21 21-4.3-4.3" />
</svg>
<input
className="w-full pl-9 pr-3 py-2 bg-nb-gray-920 border border-nb-gray-800/40 rounded-md text-sm text-nb-gray-100 placeholder-nb-gray-500 focus:outline-none focus:border-nb-gray-600 transition-colors"
placeholder="Search by name, IP or status..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2 ml-auto">
<ActionButton onClick={load}>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</ActionButton>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-500 rounded-md text-red-400 text-xs">
{error}
</div>
)}
{/* Summary */}
{peers.length > 0 && (
<div className="mb-3 text-xs text-nb-gray-400">
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
</div>
)}
{/* Table */}
{loading && peers.length === 0 ? (
<TableSkeleton />
) : peers.length === 0 ? (
<EmptyState />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-nb-gray-400 text-sm">
No peers match your search.
<button onClick={() => setSearch('')} className="ml-2 text-netbird hover:underline">Clear search</button>
</div>
) : (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-nb-gray-900 border-b border-nb-gray-900/60">
<SortableHeader label="Peer" sortKey="fqdn" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="IP" sortKey="ip" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Status" sortKey="status" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Connection</th>
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide">Transfer</th>
</tr>
</thead>
<tbody>
{filtered.map(p => (
<PeerRow key={p.pubKey} peer={p} />
))}
</tbody>
</table>
<div className="px-4 py-2.5 bg-nb-gray-940 border-t border-nb-gray-900/60 text-xs text-nb-gray-400">
Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
</div>
</div>
)}
</div>
)
}
/* ---- Row ---- */
function PeerRow({ peer }: { peer: PeerInfo }) {
const name = peerName(peer)
const connected = peer.connStatus === 'Connected'
return (
<tr className="border-b border-nb-gray-900/40 hover:bg-nb-gray-940 transition-colors group/row">
{/* Peer name */}
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-3 min-w-[160px]">
<PeerSquare name={name} connected={connected} />
<div className="flex flex-col">
<span className="font-medium text-sm text-nb-gray-100 truncate max-w-[200px]" title={peer.fqdn}>{name}</span>
{peer.networks && peer.networks.length > 0 && (
<span className="text-xs text-nb-gray-500 mt-0.5">{peer.networks.length} network{peer.networks.length !== 1 ? 's' : ''}</span>
)}
</div>
</div>
</td>
{/* IP */}
<td className="px-4 py-3 align-middle">
<span className="font-mono text-[0.82rem] text-nb-gray-300">{peer.ip || '—'}</span>
</td>
{/* Status */}
<td className="px-4 py-3 align-middle">
<StatusBadge status={peer.connStatus} />
</td>
{/* Connection type */}
<td className="px-4 py-3 align-middle">
<div className="flex flex-col gap-0.5">
{connected ? (
<>
<span className="text-xs text-nb-gray-300">
{peer.relayed ? 'Relayed' : 'Direct'}{' '}
{peer.rosenpassEnabled && (
<span className="text-green-400" title="Rosenpass post-quantum security enabled">PQ</span>
)}
</span>
{peer.relayed && peer.relayAddress && (
<span className="text-xs text-nb-gray-500 font-mono" title={peer.relayAddress}>
via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
</span>
)}
{!peer.relayed && peer.localIceType && (
<span className="text-xs text-nb-gray-500">{peer.localIceType} / {peer.remoteIceType}</span>
)}
</>
) : (
<span className="text-nb-gray-600"></span>
)}
</div>
</td>
{/* Latency */}
<td className="px-4 py-3 align-middle">
<span className={`text-sm ${peer.latencyMs > 0 ? 'text-nb-gray-300' : 'text-nb-gray-600'}`}>
{formatLatency(peer.latencyMs)}
</span>
</td>
{/* Transfer */}
<td className="px-4 py-3 align-middle">
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
<div className="flex flex-col gap-0.5 text-xs">
<span className="text-nb-gray-400">
<span className="text-green-400/70" title="Received">&#8595;</span> {formatBytes(peer.bytesRx)}
</span>
<span className="text-nb-gray-400">
<span className="text-blue-400/70" title="Sent">&#8593;</span> {formatBytes(peer.bytesTx)}
</span>
</div>
) : (
<span className="text-nb-gray-600"></span>
)}
</td>
</tr>
)
}
/* ---- Peer Icon Square ---- */
function PeerSquare({ name, connected }: { name: string; connected: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div className="relative h-10 w-10 shrink-0 rounded-md bg-nb-gray-800 flex items-center justify-center text-sm font-medium text-nb-gray-100 uppercase">
{initials}
<div className={`absolute bottom-0 right-0 h-2 w-2 rounded-full z-10 ${connected ? 'bg-green-500' : 'bg-nb-gray-700'}`} />
<div className="absolute bottom-0 right-0 h-3 w-3 bg-nb-gray-950 rounded-tl-[8px] rounded-br group-hover/row:bg-nb-gray-940 transition-colors" />
</div>
)
}
/* ---- Status Badge ---- */
function StatusBadge({ status }: { status: string }) {
const connected = status === 'Connected'
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
connected
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-nb-gray-800/50 text-nb-gray-400 border border-nb-gray-800'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-nb-gray-600'}`} />
{status || 'Unknown'}
</span>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<th
className="px-4 py-3 text-left text-xs font-medium text-nb-gray-400 uppercase tracking-wide cursor-pointer select-none hover:text-nb-gray-300 transition-colors"
onClick={() => onSort(sortKey)}
>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</th>
)
}
/* ---- Action Button ---- */
function ActionButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-nb-gray-920 border border-nb-gray-800/40 text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100 rounded-md transition-colors"
>
{children}
</button>
)
}
/* ---- Empty State ---- */
function EmptyState() {
return (
<div className="rounded-md border border-nb-gray-900/60 bg-nb-gray-920 py-16 flex flex-col items-center gap-3">
<div className="h-12 w-12 rounded-lg bg-nb-gray-800 flex items-center justify-center">
<svg className="w-6 h-6 text-nb-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<p className="text-sm text-nb-gray-400">No peers found. Connect to a network to see peers.</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div className="rounded-md border border-nb-gray-900/60 overflow-hidden">
<div className="bg-nb-gray-900 h-11" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-4 border-b border-nb-gray-900/40 animate-pulse">
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-md bg-nb-gray-800" />
<div className="h-4 w-28 rounded bg-nb-gray-800" />
</div>
<div className="h-4 w-24 rounded bg-nb-gray-800" />
<div className="h-5 w-20 rounded-full bg-nb-gray-800" />
<div className="h-4 w-16 rounded bg-nb-gray-800" />
<div className="h-4 w-14 rounded bg-nb-gray-800" />
<div className="h-4 w-16 rounded bg-nb-gray-800" />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime'
import type { ProfileInfo } from '../bindings'
export default function Profiles() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([])
const [newName, setNewName] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [confirm, setConfirm] = useState<{ action: string; profile: string } | null>(null)
async function refresh() {
try {
console.log('[Profiles] calling services.ProfileService.ListProfiles')
const data = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.ListProfiles') as ProfileInfo[]
console.log('[Profiles] ListProfiles returned', data?.length ?? 0, 'profiles')
setProfiles(data ?? [])
} catch (e) {
console.error('[Profiles] ListProfiles error:', e)
setError(String(e))
}
}
useEffect(() => { refresh() }, [])
function showInfo(msg: string) {
setInfo(msg)
setTimeout(() => setInfo(null), 3000)
}
async function handleConfirm() {
if (!confirm) return
setLoading(true)
setError(null)
try {
if (confirm.action === 'switch') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.SwitchProfile', confirm.profile)
else if (confirm.action === 'remove') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.RemoveProfile', confirm.profile)
else if (confirm.action === 'logout') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.Logout', confirm.profile)
showInfo(`${confirm.action === 'switch' ? 'Switched to' : confirm.action === 'remove' ? 'Removed' : 'Deregistered from'} profile '${confirm.profile}'`)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setConfirm(null)
}
}
async function handleAdd() {
if (!newName.trim()) return
setLoading(true)
setError(null)
try {
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.AddProfile', newName.trim())
showInfo(`Profile '${newName.trim()}' created`)
setNewName('')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Profiles</h1>
{error && (
<div className="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
{info && (
<div className="mb-4 p-3 bg-green-900/50 border border-green-700 rounded-lg text-green-300 text-sm">
{info}
</div>
)}
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-nb-gray-920 rounded-xl p-6 max-w-sm w-full mx-4 border border-nb-gray-900">
<h2 className="text-lg font-semibold mb-2 capitalize">
{confirm.action === 'switch' ? 'Switch Profile' : confirm.action === 'remove' ? 'Remove Profile' : 'Deregister Profile'}
</h2>
<p className="text-nb-gray-300 text-sm mb-5">
{confirm.action === 'switch' && `Switch to profile '${confirm.profile}'?`}
{confirm.action === 'remove' && `Delete profile '${confirm.profile}'? This cannot be undone.`}
{confirm.action === 'logout' && `Deregister from '${confirm.profile}'?`}
</p>
<div className="flex gap-3 justify-end">
<button onClick={() => setConfirm(null)} className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg">
Cancel
</button>
<button onClick={handleConfirm} disabled={loading} className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg">
Confirm
</button>
</div>
</div>
</div>
)}
{/* Profile list */}
<div className="bg-nb-gray-920 rounded-xl border border-nb-gray-900 overflow-hidden mb-6">
{profiles.length === 0 ? (
<div className="p-4 text-nb-gray-400 text-sm">No profiles found.</div>
) : (
profiles.map(p => (
<div key={p.name} className="flex items-center gap-3 px-4 py-3 border-b border-nb-gray-900 last:border-0">
<span className="text-green-400 w-5 text-center">
{p.isActive ? '✓' : ''}
</span>
<span className="flex-1 font-medium">{p.name}</span>
{p.isActive && <span className="text-xs text-netbird px-2 py-0.5 bg-netbird-950/40 rounded-full">Active</span>}
<div className="flex gap-2">
{!p.isActive && (
<button
onClick={() => setConfirm({ action: 'switch', profile: p.name })}
className="px-3 py-1 text-xs bg-netbird-600 hover:bg-netbird-500 rounded transition-colors"
>
Select
</button>
)}
<button
onClick={() => setConfirm({ action: 'logout', profile: p.name })}
className="px-3 py-1 text-xs bg-nb-gray-900 hover:bg-nb-gray-800 rounded transition-colors"
>
Deregister
</button>
<button
onClick={() => setConfirm({ action: 'remove', profile: p.name })}
className="px-3 py-1 text-xs bg-red-900 hover:bg-red-800 rounded transition-colors"
>
Remove
</button>
</div>
</div>
))
)}
</div>
{/* Add new profile */}
<div className="flex gap-3">
<input
className="flex-1 px-3 py-2 bg-nb-gray-920 border border-nb-gray-800 rounded-lg text-sm focus:outline-none focus:border-netbird"
placeholder="New profile name"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
<button
onClick={handleAdd}
disabled={!newName.trim() || loading}
className="px-4 py-2 text-sm bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg transition-colors"
>
Add Profile
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime'
import type { ConfigInfo } from '../bindings'
async function getConfig(): Promise<ConfigInfo | null> {
try {
console.log('[Settings] calling services.SettingsService.GetConfig')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.GetConfig')
console.log('[Settings] GetConfig result:', JSON.stringify(result))
return result as ConfigInfo
} catch (e) {
console.error('[Settings] GetConfig error:', e)
return null
}
}
async function setConfig(cfg: ConfigInfo): Promise<void> {
console.log('[Settings] calling services.SettingsService.SetConfig')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.SetConfig', cfg)
}
type Tab = 'connection' | 'network' | 'security'
export default function Settings() {
const [config, setConfigState] = useState<ConfigInfo | null>(null)
const [tab, setTab] = useState<Tab>('connection')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getConfig().then(c => { if (c) setConfigState(c) })
}, [])
function update<K extends keyof ConfigInfo>(key: K, value: ConfigInfo[K]) {
setConfigState(prev => prev ? { ...prev, [key]: value } : prev)
}
async function handleSave() {
if (!config) return
setSaving(true)
setError(null)
setSaved(false)
try {
await setConfig(config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setError(String(e))
} finally {
setSaving(false)
}
}
if (!config) {
return <div className="text-nb-gray-400">Loading settings</div>
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-nb-gray-920 p-1 rounded-lg border border-nb-gray-900">
{(['connection', 'network', 'security'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 rounded text-sm font-medium capitalize transition-colors ${
tab === t ? 'bg-netbird text-white' : 'text-nb-gray-400 hover:text-white'
}`}
>
{t}
</button>
))}
</div>
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 space-y-5">
{tab === 'connection' && (
<>
<Field label="Management URL">
<input
className="input"
value={config.managementUrl}
onChange={e => update('managementUrl', e.target.value)}
placeholder="https://api.netbird.io:443"
/>
</Field>
<Field label="Admin URL">
<input
className="input"
value={config.adminUrl}
onChange={e => update('adminUrl', e.target.value)}
/>
</Field>
<Field label="Pre-shared Key">
<input
className="input"
type="password"
value={config.preSharedKey}
onChange={e => update('preSharedKey', e.target.value)}
placeholder="Leave empty to clear"
/>
</Field>
<Toggle
label="Connect automatically when service starts"
checked={!config.disableAutoConnect}
onChange={v => update('disableAutoConnect', !v)}
/>
<Toggle
label="Enable notifications"
checked={!config.disableNotifications}
onChange={v => update('disableNotifications', !v)}
/>
</>
)}
{tab === 'network' && (
<>
<Field label="Interface Name">
<input
className="input"
value={config.interfaceName}
onChange={e => update('interfaceName', e.target.value)}
placeholder="netbird0"
/>
</Field>
<Field label="WireGuard Port">
<input
className="input"
type="number"
min={1}
max={65535}
value={config.wireguardPort}
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
placeholder="51820"
/>
</Field>
<Toggle
label="Enable lazy connections (experimental)"
checked={config.lazyConnectionEnabled}
onChange={v => update('lazyConnectionEnabled', v)}
/>
<Toggle
label="Block inbound connections"
checked={config.blockInbound}
onChange={v => update('blockInbound', v)}
/>
</>
)}
{tab === 'security' && (
<>
<Toggle
label="Allow SSH connections"
checked={config.serverSshAllowed}
onChange={v => update('serverSshAllowed', v)}
/>
<Toggle
label="Enable post-quantum security via Rosenpass"
checked={config.rosenpassEnabled}
onChange={v => update('rosenpassEnabled', v)}
/>
<Toggle
label="Rosenpass permissive mode"
checked={config.rosenpassPermissive}
onChange={v => update('rosenpassPermissive', v)}
/>
</>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
>
{saving ? 'Saving…' : 'Save'}
</button>
{saved && <span className="text-green-400 text-sm">Saved!</span>}
{error && <span className="text-red-400 text-sm">{error}</span>}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-sm text-nb-gray-400 mb-1.5">{label}</label>
{children}
</div>
)
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-3 cursor-pointer">
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="toggle-track"
>
<span className="toggle-thumb" />
</button>
<span className="text-sm">{label}</span>
</label>
)
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect, useCallback } from 'react'
import { Events, Call } from '@wailsio/runtime'
import type { StatusInfo } from '../bindings'
async function getStatus(): Promise<StatusInfo | null> {
try {
console.log('[Dashboard] calling services.ConnectionService.GetStatus')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.GetStatus')
console.log('[Dashboard] GetStatus result:', JSON.stringify(result))
return result as StatusInfo
} catch (e) {
console.error('[Dashboard] GetStatus error:', e)
return null
}
}
async function connect(): Promise<void> {
console.log('[Dashboard] calling services.ConnectionService.Connect')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Connect')
}
async function disconnect(): Promise<void> {
console.log('[Dashboard] calling services.ConnectionService.Disconnect')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect')
}
function statusColor(status: string): string {
switch (status) {
case 'Connected': return 'text-green-400'
case 'Connecting': return 'text-yellow-400'
case 'Disconnected': return 'text-nb-gray-400'
default: return 'text-red-400'
}
}
function statusDot(status: string): string {
switch (status) {
case 'Connected': return 'bg-green-400'
case 'Connecting': return 'bg-yellow-400 animate-pulse'
case 'Disconnected': return 'bg-nb-gray-600'
default: return 'bg-red-400'
}
}
export default function Status() {
const [status, setStatus] = useState<StatusInfo | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
const s = await getStatus()
if (s) setStatus(s)
}, [])
useEffect(() => {
refresh()
// Poll every 10 seconds as fallback (push events handle real-time updates)
const id = setInterval(refresh, 10000)
// Also listen for push events from the tray
const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => {
if (event.data[0]) setStatus(event.data[0])
})
return () => {
clearInterval(id)
if (typeof unsub === 'function') unsub()
}
}, [refresh])
async function handleConnect() {
setBusy(true)
setError(null)
try {
await connect()
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function handleDisconnect() {
setBusy(true)
setError(null)
try {
await disconnect()
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
const isConnected = status?.status === 'Connected'
const isConnecting = status?.status === 'Connecting'
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Status</h1>
{/* Status card */}
<div className="bg-nb-gray-920 rounded-xl p-6 mb-6 border border-nb-gray-900">
<div className="flex items-center gap-3 mb-4">
<span className={`w-3 h-3 rounded-full ${status ? statusDot(status.status) : 'bg-nb-gray-600'}`} />
<span className={`text-xl font-semibold ${status ? statusColor(status.status) : 'text-nb-gray-400'}`}>
{status?.status ?? 'Loading…'}
</span>
</div>
{status && (
<div className="grid grid-cols-2 gap-3 text-sm">
{status.ip && (
<>
<span className="text-nb-gray-400">IP Address</span>
<span className="font-mono">{status.ip}</span>
</>
)}
{status.fqdn && (
<>
<span className="text-nb-gray-400">Hostname</span>
<span className="font-mono">{status.fqdn}</span>
</>
)}
{status.connectedPeers > 0 && (
<>
<span className="text-nb-gray-400">Connected Peers</span>
<span>{status.connectedPeers}</span>
</>
)}
</div>
)}
</div>
{/* Action button */}
<div className="flex gap-3">
{!isConnected && !isConnecting && (
<button
onClick={handleConnect}
disabled={busy}
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 disabled:opacity-50 rounded-lg font-medium transition-colors"
>
{busy ? 'Connecting…' : 'Connect'}
</button>
)}
{(isConnected || isConnecting) && (
<button
onClick={handleDisconnect}
disabled={busy}
className="px-6 py-2.5 bg-nb-gray-800 hover:bg-nb-gray-600 disabled:opacity-50 rounded-lg font-medium transition-colors"
>
{busy ? 'Disconnecting…' : 'Disconnect'}
</button>
)}
</div>
{error && (
<div className="mt-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react'
import { Call } from '@wailsio/runtime'
import type { InstallerResult } from '../bindings'
type UpdateState = 'idle' | 'triggering' | 'polling' | 'success' | 'failed' | 'timeout'
export default function Update() {
const [state, setState] = useState<UpdateState>('idle')
const [dots, setDots] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const abortRef = useRef<AbortController | null>(null)
// Animate dots when polling
useEffect(() => {
if (state !== 'polling') return
let count = 0
const id = setInterval(() => {
count = (count + 1) % 4
setDots('.'.repeat(count))
}, 500)
return () => clearInterval(id)
}, [state])
async function handleTriggerUpdate() {
abortRef.current?.abort()
abortRef.current = new AbortController()
setState('triggering')
setErrorMsg('')
try {
console.log('[Update] calling services.UpdateService.TriggerUpdate')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.TriggerUpdate')
} catch (e) {
console.error('[Update] TriggerUpdate error:', e)
setErrorMsg(String(e))
setState('failed')
return
}
setState('polling')
// Poll for installer result (up to 15 minutes handled server-side)
try {
console.log('[Update] calling services.UpdateService.GetInstallerResult')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult
console.log('[Update] GetInstallerResult:', JSON.stringify(result))
if (result?.success) {
setState('success')
} else {
setErrorMsg(result?.errorMsg ?? 'Update failed')
setState('failed')
}
} catch (e) {
// If the daemon restarts, the gRPC call may fail — treat as success
setState('success')
}
}
return (
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-2">Update</h1>
<p className="text-nb-gray-400 text-sm mb-8">
Trigger an automatic client update managed by the NetBird daemon.
</p>
<div className="bg-nb-gray-920 rounded-xl p-6 border border-nb-gray-900 text-center">
{state === 'idle' && (
<>
<p className="text-nb-gray-300 mb-5">Click below to trigger a daemon-managed update.</p>
<button
onClick={handleTriggerUpdate}
className="px-6 py-2.5 bg-netbird hover:bg-netbird-500 rounded-lg font-medium transition-colors"
>
Trigger Update
</button>
</>
)}
{state === 'triggering' && (
<p className="text-yellow-300 animate-pulse">Triggering update</p>
)}
{state === 'polling' && (
<div>
<p className="text-yellow-300 text-lg mb-2">Updating{dots}</p>
<p className="text-nb-gray-400 text-sm">The daemon is installing the update. Please wait.</p>
</div>
)}
{state === 'success' && (
<div>
<p className="text-green-400 text-lg font-semibold mb-2">Update Successful!</p>
<p className="text-nb-gray-300 text-sm">The client has been updated. You may need to restart.</p>
</div>
)}
{state === 'failed' && (
<div>
<p className="text-red-400 text-lg font-semibold mb-2">Update Failed</p>
{errorMsg && <p className="text-nb-gray-300 text-sm mb-4">{errorMsg}</p>}
<button
onClick={() => { setState('idle'); setErrorMsg('') }}
className="px-4 py-2 text-sm bg-nb-gray-900 hover:bg-nb-gray-800 rounded-lg transition-colors"
>
Try Again
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

84
client/uiwails/grpc.go Normal file
View File

@@ -0,0 +1,84 @@
//go:build !(linux && 386)
package main
import (
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)
const (
defaultFailTimeout = 3 * time.Second
failFastTimeout = time.Second
)
// GRPCClient manages a single persistent gRPC connection to the NetBird daemon.
type GRPCClient struct {
addr string
mu sync.Mutex
conn *grpc.ClientConn
client proto.DaemonServiceClient
}
// NewGRPCClient creates a new GRPCClient for the given daemon address.
func NewGRPCClient(addr string) *GRPCClient {
return &GRPCClient{addr: addr}
}
// GetClient returns a cached DaemonServiceClient, creating the connection on first use.
func (g *GRPCClient) GetClient(timeout time.Duration) (proto.DaemonServiceClient, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.client != nil {
return g.client, nil
}
target := g.addr
if strings.HasPrefix(target, "tcp://") {
target = strings.TrimPrefix(target, "tcp://")
} else if strings.HasPrefix(target, "unix://") {
target = "unix:" + strings.TrimPrefix(target, "unix://")
}
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUserAgent(getUIUserAgent()),
)
if err != nil {
return nil, err
}
g.conn = conn
g.client = proto.NewDaemonServiceClient(conn)
log.Debugf("gRPC connection established to %s", g.addr)
return g.client, nil
}
// Close closes the underlying gRPC connection.
func (g *GRPCClient) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.conn != nil {
err := g.conn.Close()
g.conn = nil
g.client = nil
return err
}
return nil
}
func getUIUserAgent() string {
return "netbird-fancyui/" + version.NetbirdVersion()
}

31
client/uiwails/icons.go Normal file
View File

@@ -0,0 +1,31 @@
//go:build !(linux && 386)
package main
import _ "embed"
//go:embed assets/netbird-systemtray-disconnected.png
var iconDisconnected []byte
//go:embed assets/netbird-systemtray-connected.png
var iconConnected []byte
//go:embed assets/netbird-systemtray-connecting.png
var iconConnecting []byte
//go:embed assets/netbird-systemtray-error.png
var iconError []byte
// iconForStatus returns the appropriate tray icon bytes for the given status string.
func iconForStatus(status string) []byte {
switch status {
case "Connected":
return iconConnected
case "Connecting":
return iconConnecting
case "Disconnected", "":
return iconDisconnected
default:
return iconError
}
}

129
client/uiwails/main.go Normal file
View File

@@ -0,0 +1,129 @@
//go:build !(linux && 386)
package main
import (
"context"
"embed"
"flag"
"os"
"runtime"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/uiwails/event"
"github.com/netbirdio/netbird/client/uiwails/process"
"github.com/netbirdio/netbird/client/uiwails/services"
)
//go:embed frontend/dist
var frontendFS embed.FS
var (
daemonAddr = flag.String("daemon-addr", defaultDaemonAddr(), "NetBird daemon gRPC address")
)
func defaultDaemonAddr() string {
if runtime.GOOS == "windows" {
return "tcp://127.0.0.1:41731"
}
return "unix:///var/run/netbird.sock"
}
func main() {
flag.Parse()
// Single-instance guard — if another instance is running, show its window and exit.
if pid, running, err := process.IsAnotherProcessRunning(); err == nil && running {
log.Infof("another instance is running (pid %d), signalling it to show window", pid)
if err := sendShowWindowSignal(pid); err != nil {
log.Warnf("send show window signal: %v", err)
}
os.Exit(0)
}
grpcClient := NewGRPCClient(*daemonAddr)
connSvc := services.NewConnectionService(grpcClient)
settingsSvc := services.NewSettingsService(grpcClient)
networkSvc := services.NewNetworkService(grpcClient)
profileSvc := services.NewProfileService(grpcClient)
peersSvc := services.NewPeersService(grpcClient)
debugSvc := services.NewDebugService(grpcClient)
updateSvc := services.NewUpdateService(grpcClient)
notifSvc := notifications.New()
app := application.New(application.Options{
Name: "NetBird",
Description: "NetBird VPN client",
Services: []application.Service{
application.NewService(connSvc),
application.NewService(settingsSvc),
application.NewService(networkSvc),
application.NewService(profileSvc),
application.NewService(peersSvc),
application.NewService(debugSvc),
application.NewService(updateSvc),
application.NewService(notifSvc),
},
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(frontendFS),
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
},
})
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "NetBird",
Width: 900,
Height: 650,
Hidden: true, // start hidden — tray is the primary interface
URL: "/",
AlwaysOnTop: false,
DisableResize: false,
Windows: application.WindowsWindow{
HiddenOnTaskbar: true,
},
})
// Hide instead of quit when user closes the window.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
e.Cancel()
window.Hide()
})
// Register an in-process StatusNotifierWatcher so the tray works on
// minimal WMs (Fluxbox, OpenBox, i3…) that don't ship one themselves.
startStatusNotifierWatcher()
tray := newTrayManager(app, window, connSvc, settingsSvc, networkSvc, profileSvc)
tray.Setup(iconDisconnected)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Signal handler: SIGUSR1 on Unix, Windows Event on Windows.
setupSignalHandler(ctx, window)
// Daemon event stream → desktop notifications.
notify := func(title, body string) {
if err := notifSvc.SendNotification(notifications.NotificationOptions{
ID: "netbird-event",
Title: title,
Body: body,
}); err != nil {
log.Warnf("send notification: %v", err)
}
}
evtManager := event.NewManager(*daemonAddr, notify)
go evtManager.Start(ctx)
if err := app.Run(); err != nil {
log.Fatalf("app run: %v", err)
}
}

View File

@@ -0,0 +1,39 @@
package process
import (
"os"
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v3/process"
)
// IsAnotherProcessRunning returns the PID and true if another instance of the
// same binary is already running for the current OS user.
func IsAnotherProcessRunning() (int32, bool, error) {
processes, err := process.Processes()
if err != nil {
return 0, false, err
}
pid := os.Getpid()
processName := strings.ToLower(filepath.Base(os.Args[0]))
for _, p := range processes {
if int(p.Pid) == pid {
continue
}
runningProcessPath, err := p.Exe()
if err != nil {
continue
}
runningProcessName := strings.ToLower(filepath.Base(runningProcessPath))
if runningProcessName == processName && isProcessOwnedByCurrentUser(p) {
return p.Pid, true, nil
}
}
return 0, false, nil
}

View File

@@ -0,0 +1,25 @@
//go:build !windows
package process
import (
"os"
"github.com/shirou/gopsutil/v3/process"
log "github.com/sirupsen/logrus"
)
func isProcessOwnedByCurrentUser(p *process.Process) bool {
currentUserID := os.Getuid()
uids, err := p.Uids()
if err != nil {
log.Errorf("get process uids: %v", err)
return false
}
for _, id := range uids {
if int(id) == currentUserID {
return true
}
}
return false
}

View File

@@ -0,0 +1,24 @@
package process
import (
"os/user"
"github.com/shirou/gopsutil/v3/process"
log "github.com/sirupsen/logrus"
)
func isProcessOwnedByCurrentUser(p *process.Process) bool {
processUsername, err := p.Username()
if err != nil {
log.Errorf("get process username error: %v", err)
return false
}
currUser, err := user.Current()
if err != nil {
log.Errorf("get current user error: %v", err)
return false
}
return processUsername == currUser.Username
}

View File

@@ -0,0 +1,112 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// ConnectionService exposes connect/disconnect/status operations to the Wails frontend.
type ConnectionService struct {
grpcClient GRPCClientIface
}
// NewConnectionService creates a new ConnectionService.
func NewConnectionService(g GRPCClientIface) *ConnectionService {
return &ConnectionService{grpcClient: g}
}
// GetStatus returns the current daemon status.
func (s *ConnectionService) GetStatus() (*StatusInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
log.Debugf("GetStatus: failed to get gRPC client: %v", err)
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
log.Warnf("GetStatus: status RPC failed: %v", err)
return nil, fmt.Errorf("status rpc: %w", err)
}
log.Debugf("GetStatus: daemon responded status=%q daemonVersion=%q fullStatus=%v",
resp.Status, resp.DaemonVersion, resp.FullStatus != nil)
info := &StatusInfo{
Status: resp.Status,
}
if resp.FullStatus != nil && resp.FullStatus.LocalPeerState != nil {
lp := resp.FullStatus.LocalPeerState
info.IP = lp.GetIP()
info.PublicKey = lp.GetPubKey()
info.Fqdn = lp.GetFqdn()
log.Debugf("GetStatus: localPeer ip=%q fqdn=%q pubKey=%q", info.IP, info.Fqdn, info.PublicKey)
} else if resp.FullStatus == nil {
log.Warnf("GetStatus: fullStatus is nil — daemon may not support full status or request flag was not set")
} else {
log.Debugf("GetStatus: fullStatus present but LocalPeerState is nil")
}
if resp.FullStatus != nil {
info.ConnectedPeers = len(resp.FullStatus.GetPeers())
log.Debugf("GetStatus: connectedPeers=%d", info.ConnectedPeers)
}
return info, nil
}
// Connect sends an Up request to the daemon.
func (s *ConnectionService) Connect() error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
log.Errorf("Up rpc failed: %v", err)
return fmt.Errorf("connect: %w", err)
}
return nil
}
// Disconnect sends a Down request to the daemon.
func (s *ConnectionService) Disconnect() error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
log.Errorf("Down rpc failed: %v", err)
return fmt.Errorf("disconnect: %w", err)
}
return nil
}
// StatusInfo holds simplified status information for the frontend.
type StatusInfo struct {
Status string `json:"status"`
IP string `json:"ip"`
PublicKey string `json:"publicKey"`
Fqdn string `json:"fqdn"`
ConnectedPeers int `json:"connectedPeers"`
}

View File

@@ -0,0 +1,196 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// DebugService exposes debug bundle creation and log-level control to the Wails frontend.
type DebugService struct {
grpcClient GRPCClientIface
}
// NewDebugService creates a new DebugService.
func NewDebugService(g GRPCClientIface) *DebugService {
return &DebugService{grpcClient: g}
}
// DebugBundleParams holds the parameters for creating a debug bundle.
type DebugBundleParams struct {
Anonymize bool `json:"anonymize"`
SystemInfo bool `json:"systemInfo"`
Upload bool `json:"upload"`
UploadURL string `json:"uploadUrl"`
RunDurationMins int `json:"runDurationMins"`
EnablePersistence bool `json:"enablePersistence"`
}
// DebugBundleResult holds the result of creating a debug bundle.
type DebugBundleResult struct {
LocalPath string `json:"localPath"`
UploadedKey string `json:"uploadedKey"`
UploadFailureReason string `json:"uploadFailureReason"`
}
// CreateDebugBundle creates a debug bundle via the daemon.
func (s *DebugService) CreateDebugBundle(params DebugBundleParams) (*DebugBundleResult, error) {
conn, err := s.grpcClient.GetClient(time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if params.RunDurationMins > 0 {
if err := s.configureForDebug(ctx, conn, params); err != nil {
return nil, err
}
}
req := &proto.DebugBundleRequest{
Anonymize: params.Anonymize,
SystemInfo: params.SystemInfo,
}
if params.Upload && params.UploadURL != "" {
req.UploadURL = params.UploadURL
}
resp, err := conn.DebugBundle(ctx, req)
if err != nil {
log.Errorf("DebugBundle rpc failed: %v", err)
return nil, fmt.Errorf("create debug bundle: %w", err)
}
return &DebugBundleResult{
LocalPath: resp.GetPath(),
UploadedKey: resp.GetUploadedKey(),
UploadFailureReason: resp.GetUploadFailureReason(),
}, nil
}
func (s *DebugService) configureForDebug(ctx context.Context, conn proto.DaemonServiceClient, params DebugBundleParams) error {
statusResp, err := conn.Status(ctx, &proto.StatusRequest{})
if err != nil {
return fmt.Errorf("get status: %w", err)
}
wasConnected := statusResp.Status == "Connected" || statusResp.Status == "Connecting"
logLevelResp, err := conn.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
if err != nil {
return fmt.Errorf("get log level: %w", err)
}
originalLogLevel := logLevelResp.GetLevel()
// Set trace log level
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil {
return fmt.Errorf("set log level: %w", err)
}
// Bring service down then up to capture full connection logs
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
log.Warnf("bring down for debug: %v", err)
}
time.Sleep(time.Second)
if params.EnablePersistence {
if _, err := conn.SetSyncResponsePersistence(ctx, &proto.SetSyncResponsePersistenceRequest{Enabled: true}); err != nil {
log.Warnf("enable sync persistence: %v", err)
}
}
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
return fmt.Errorf("bring service up: %w", err)
}
time.Sleep(3 * time.Second)
if _, err := conn.StartCPUProfile(ctx, &proto.StartCPUProfileRequest{}); err != nil {
log.Warnf("start CPU profiling: %v", err)
}
// Wait for the collection duration
collectionDur := time.Duration(params.RunDurationMins) * time.Minute
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(collectionDur):
}
if _, err := conn.StopCPUProfile(ctx, &proto.StopCPUProfileRequest{}); err != nil {
log.Warnf("stop CPU profiling: %v", err)
}
// Restore original state
if !wasConnected {
if _, err := conn.Down(ctx, &proto.DownRequest{}); err != nil {
log.Warnf("restore down state: %v", err)
}
}
if originalLogLevel < proto.LogLevel_TRACE {
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: originalLogLevel}); err != nil {
log.Warnf("restore log level: %v", err)
}
}
return nil
}
// GetLogLevel returns the current daemon log level.
func (s *DebugService) GetLogLevel() (string, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return "", fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
if err != nil {
return "", fmt.Errorf("get log level rpc: %w", err)
}
return resp.GetLevel().String(), nil
}
// SetLogLevel sets the daemon log level.
func (s *DebugService) SetLogLevel(level string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var protoLevel proto.LogLevel
switch level {
case "TRACE":
protoLevel = proto.LogLevel_TRACE
case "DEBUG":
protoLevel = proto.LogLevel_DEBUG
case "INFO":
protoLevel = proto.LogLevel_INFO
case "WARN", "WARNING":
protoLevel = proto.LogLevel_WARN
case "ERROR":
protoLevel = proto.LogLevel_ERROR
default:
protoLevel = proto.LogLevel_INFO
}
if _, err := conn.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: protoLevel}); err != nil {
return fmt.Errorf("set log level rpc: %w", err)
}
return nil
}

View File

@@ -0,0 +1,14 @@
//go:build !(linux && 386)
package services
import (
"time"
"github.com/netbirdio/netbird/client/proto"
)
// GRPCClientIface is the interface services use to obtain a daemon client.
type GRPCClientIface interface {
GetClient(timeout time.Duration) (proto.DaemonServiceClient, error)
}

View File

@@ -0,0 +1,222 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"sort"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// NetworkService exposes network/route management to the Wails frontend.
type NetworkService struct {
grpcClient GRPCClientIface
}
// NewNetworkService creates a new NetworkService.
func NewNetworkService(g GRPCClientIface) *NetworkService {
return &NetworkService{grpcClient: g}
}
// NetworkInfo is a serializable view of a single network/route.
type NetworkInfo struct {
ID string `json:"id"`
Range string `json:"range"`
Domains []string `json:"domains"`
Selected bool `json:"selected"`
ResolvedIPs map[string][]string `json:"resolvedIPs"`
}
// ListNetworks returns all networks from the daemon.
func (s *NetworkService) ListNetworks() ([]NetworkInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{})
if err != nil {
return nil, fmt.Errorf("list networks rpc: %w", err)
}
routes := make([]NetworkInfo, 0, len(resp.Routes))
for _, r := range resp.Routes {
info := NetworkInfo{
ID: r.GetID(),
Range: r.GetRange(),
Domains: r.GetDomains(),
Selected: r.GetSelected(),
}
if resolvedMap := r.GetResolvedIPs(); resolvedMap != nil {
info.ResolvedIPs = make(map[string][]string)
for domain, ipList := range resolvedMap {
info.ResolvedIPs[domain] = ipList.GetIps()
}
}
routes = append(routes, info)
}
sort.Slice(routes, func(i, j int) bool {
return strings.ToLower(routes[i].ID) < strings.ToLower(routes[j].ID)
})
return routes, nil
}
// ListOverlappingNetworks returns only networks with overlapping ranges.
func (s *NetworkService) ListOverlappingNetworks() ([]NetworkInfo, error) {
all, err := s.ListNetworks()
if err != nil {
return nil, err
}
existingRange := make(map[string][]NetworkInfo)
for _, r := range all {
if len(r.Domains) > 0 {
continue
}
existingRange[r.Range] = append(existingRange[r.Range], r)
}
var result []NetworkInfo
for _, group := range existingRange {
if len(group) > 1 {
result = append(result, group...)
}
}
return result, nil
}
// ListExitNodes returns networks with range 0.0.0.0/0 (exit nodes).
func (s *NetworkService) ListExitNodes() ([]NetworkInfo, error) {
all, err := s.ListNetworks()
if err != nil {
return nil, err
}
var result []NetworkInfo
for _, r := range all {
if r.Range == "0.0.0.0/0" {
result = append(result, r)
}
}
return result, nil
}
// SelectNetwork selects a single network by ID.
func (s *NetworkService) SelectNetwork(id string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{
NetworkIDs: []string{id},
Append: true,
}
if _, err := conn.SelectNetworks(ctx, req); err != nil {
log.Errorf("SelectNetworks rpc failed: %v", err)
return fmt.Errorf("select network: %w", err)
}
return nil
}
// DeselectNetwork deselects a single network by ID.
func (s *NetworkService) DeselectNetwork(id string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{
NetworkIDs: []string{id},
}
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
log.Errorf("DeselectNetworks rpc failed: %v", err)
return fmt.Errorf("deselect network: %w", err)
}
return nil
}
// SelectAllNetworks selects all networks.
func (s *NetworkService) SelectAllNetworks() error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{All: true}
if _, err := conn.SelectNetworks(ctx, req); err != nil {
return fmt.Errorf("select all networks: %w", err)
}
return nil
}
// DeselectAllNetworks deselects all networks.
func (s *NetworkService) DeselectAllNetworks() error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{All: true}
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
return fmt.Errorf("deselect all networks: %w", err)
}
return nil
}
// SelectNetworks selects a list of networks by ID.
func (s *NetworkService) SelectNetworks(ids []string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{NetworkIDs: ids, Append: true}
if _, err := conn.SelectNetworks(ctx, req); err != nil {
return fmt.Errorf("select networks: %w", err)
}
return nil
}
// DeselectNetworks deselects a list of networks by ID.
func (s *NetworkService) DeselectNetworks(ids []string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &proto.SelectNetworksRequest{NetworkIDs: ids}
if _, err := conn.DeselectNetworks(ctx, req); err != nil {
return fmt.Errorf("deselect networks: %w", err)
}
return nil
}

View File

@@ -0,0 +1,106 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// PeersService exposes peer listing operations to the Wails frontend.
type PeersService struct {
grpcClient GRPCClientIface
}
// NewPeersService creates a new PeersService.
func NewPeersService(g GRPCClientIface) *PeersService {
return &PeersService{grpcClient: g}
}
// GetPeers returns the list of all peers with their status information.
func (s *PeersService) GetPeers() ([]PeerInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
log.Debugf("GetPeers: failed to get gRPC client: %v", err)
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
if err != nil {
log.Warnf("GetPeers: status RPC failed: %v", err)
return nil, fmt.Errorf("status rpc: %w", err)
}
if resp.FullStatus == nil {
log.Debugf("GetPeers: fullStatus is nil")
return []PeerInfo{}, nil
}
peers := resp.FullStatus.GetPeers()
log.Debugf("GetPeers: got %d peers from daemon", len(peers))
result := make([]PeerInfo, 0, len(peers))
for _, p := range peers {
info := PeerInfo{
IP: p.GetIP(),
PubKey: p.GetPubKey(),
Fqdn: p.GetFqdn(),
ConnStatus: p.GetConnStatus(),
Relayed: p.GetRelayed(),
RelayAddress: p.GetRelayAddress(),
BytesRx: p.GetBytesRx(),
BytesTx: p.GetBytesTx(),
RosenpassEnabled: p.GetRosenpassEnabled(),
Networks: p.GetNetworks(),
LocalIceType: p.GetLocalIceCandidateType(),
RemoteIceType: p.GetRemoteIceCandidateType(),
LocalEndpoint: p.GetLocalIceCandidateEndpoint(),
RemoteEndpoint: p.GetRemoteIceCandidateEndpoint(),
}
if lat := p.GetLatency(); lat != nil {
info.LatencyMs = float64(lat.Seconds)*1000 + float64(lat.Nanos)/1e6
}
if ts := p.GetLastWireguardHandshake(); ts != nil {
info.LastHandshake = ts.AsTime().Format(time.RFC3339)
}
if ts := p.GetConnStatusUpdate(); ts != nil {
info.ConnStatusUpdate = ts.AsTime().Format(time.RFC3339)
}
result = append(result, info)
}
return result, nil
}
// PeerInfo holds simplified peer information for the frontend.
type PeerInfo struct {
IP string `json:"ip"`
PubKey string `json:"pubKey"`
Fqdn string `json:"fqdn"`
ConnStatus string `json:"connStatus"`
ConnStatusUpdate string `json:"connStatusUpdate"`
Relayed bool `json:"relayed"`
RelayAddress string `json:"relayAddress"`
LatencyMs float64 `json:"latencyMs"`
BytesRx int64 `json:"bytesRx"`
BytesTx int64 `json:"bytesTx"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
Networks []string `json:"networks"`
LastHandshake string `json:"lastHandshake"`
LocalIceType string `json:"localIceType"`
RemoteIceType string `json:"remoteIceType"`
LocalEndpoint string `json:"localEndpoint"`
RemoteEndpoint string `json:"remoteEndpoint"`
}

View File

@@ -0,0 +1,195 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"os/user"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// ProfileService exposes profile management to the Wails frontend.
type ProfileService struct {
grpcClient GRPCClientIface
}
// NewProfileService creates a new ProfileService.
func NewProfileService(g GRPCClientIface) *ProfileService {
return &ProfileService{grpcClient: g}
}
// ProfileInfo is a serializable view of a profile.
type ProfileInfo struct {
Name string `json:"name"`
IsActive bool `json:"isActive"`
}
// ActiveProfileInfo holds information about the currently active profile.
type ActiveProfileInfo struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
Email string `json:"email"`
}
// ListProfiles returns all profiles for the current OS user.
func (s *ProfileService) ListProfiles() ([]ProfileInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.ListProfiles(ctx, &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return nil, fmt.Errorf("list profiles rpc: %w", err)
}
profiles := make([]ProfileInfo, 0, len(resp.Profiles))
for _, p := range resp.Profiles {
profiles = append(profiles, ProfileInfo{
Name: p.Name,
IsActive: p.IsActive,
})
}
return profiles, nil
}
// GetActiveProfile returns the currently active profile.
func (s *ProfileService) GetActiveProfile() (*ActiveProfileInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
if err != nil {
return nil, fmt.Errorf("get active profile rpc: %w", err)
}
return &ActiveProfileInfo{
ProfileName: resp.ProfileName,
Username: resp.Username,
}, nil
}
// SwitchProfile switches to the named profile.
func (s *ProfileService) SwitchProfile(profileName string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
ProfileName: &profileName,
Username: &currUser.Username,
}); err != nil {
log.Errorf("SwitchProfile rpc failed: %v", err)
return fmt.Errorf("switch profile: %w", err)
}
return nil
}
// AddProfile creates a new profile with the given name.
func (s *ProfileService) AddProfile(profileName string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := conn.AddProfile(ctx, &proto.AddProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
}); err != nil {
log.Errorf("AddProfile rpc failed: %v", err)
return fmt.Errorf("add profile: %w", err)
}
return nil
}
// RemoveProfile removes the named profile.
func (s *ProfileService) RemoveProfile(profileName string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := conn.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
}); err != nil {
log.Errorf("RemoveProfile rpc failed: %v", err)
return fmt.Errorf("remove profile: %w", err)
}
return nil
}
// Logout deregisters the named profile.
func (s *ProfileService) Logout(profileName string) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
username := currUser.Username
if _, err := conn.Logout(ctx, &proto.LogoutRequest{
ProfileName: &profileName,
Username: &username,
}); err != nil {
log.Errorf("Logout rpc failed: %v", err)
return fmt.Errorf("logout: %w", err)
}
return nil
}

View File

@@ -0,0 +1,165 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// SettingsService exposes config get/set operations to the Wails frontend.
type SettingsService struct {
grpcClient GRPCClientIface
}
// NewSettingsService creates a new SettingsService.
func NewSettingsService(g GRPCClientIface) *SettingsService {
return &SettingsService{grpcClient: g}
}
// ConfigInfo is a serializable view of the daemon configuration.
type ConfigInfo struct {
ManagementURL string `json:"managementUrl"`
AdminURL string `json:"adminUrl"`
PreSharedKey string `json:"preSharedKey"`
InterfaceName string `json:"interfaceName"`
WireguardPort int64 `json:"wireguardPort"`
DisableAutoConnect bool `json:"disableAutoConnect"`
ServerSSHAllowed bool `json:"serverSshAllowed"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
BlockInbound bool `json:"blockInbound"`
DisableNotifications bool `json:"disableNotifications"`
}
// GetConfig retrieves the daemon configuration.
func (s *SettingsService) GetConfig() (*ConfigInfo, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := conn.GetConfig(ctx, &proto.GetConfigRequest{})
if err != nil {
return nil, fmt.Errorf("get config rpc: %w", err)
}
cfg := &ConfigInfo{
ManagementURL: resp.ManagementUrl,
AdminURL: resp.AdminURL,
PreSharedKey: resp.PreSharedKey,
InterfaceName: resp.InterfaceName,
WireguardPort: resp.WireguardPort,
DisableAutoConnect: resp.DisableAutoConnect,
ServerSSHAllowed: resp.ServerSSHAllowed,
RosenpassEnabled: resp.RosenpassEnabled,
LazyConnectionEnabled: resp.LazyConnectionEnabled,
BlockInbound: resp.BlockInbound,
DisableNotifications: resp.DisableNotifications,
}
return cfg, nil
}
// SetConfig pushes configuration changes to the daemon.
func (s *SettingsService) SetConfig(cfg ConfigInfo) error {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// The SetConfigRequest uses optional pointer fields for most settings.
req := &proto.SetConfigRequest{
ManagementUrl: cfg.ManagementURL,
AdminURL: cfg.AdminURL,
RosenpassEnabled: &cfg.RosenpassEnabled,
InterfaceName: &cfg.InterfaceName,
WireguardPort: &cfg.WireguardPort,
OptionalPreSharedKey: &cfg.PreSharedKey,
DisableAutoConnect: &cfg.DisableAutoConnect,
ServerSSHAllowed: &cfg.ServerSSHAllowed,
RosenpassPermissive: &cfg.RosenpassPermissive,
DisableNotifications: &cfg.DisableNotifications,
LazyConnectionEnabled: &cfg.LazyConnectionEnabled,
BlockInbound: &cfg.BlockInbound,
}
if _, err := conn.SetConfig(ctx, req); err != nil {
log.Errorf("SetConfig rpc failed: %v", err)
return fmt.Errorf("set config: %w", err)
}
return nil
}
// ToggleSSH toggles the SSH server allowed setting.
func (s *SettingsService) ToggleSSH(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.ServerSSHAllowed = enabled
return s.SetConfig(*cfg)
}
// ToggleAutoConnect toggles the auto-connect setting.
func (s *SettingsService) ToggleAutoConnect(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.DisableAutoConnect = !enabled
return s.SetConfig(*cfg)
}
// ToggleRosenpass toggles the Rosenpass quantum resistance setting.
func (s *SettingsService) ToggleRosenpass(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.RosenpassEnabled = enabled
return s.SetConfig(*cfg)
}
// ToggleLazyConn toggles the lazy connections setting.
func (s *SettingsService) ToggleLazyConn(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.LazyConnectionEnabled = enabled
return s.SetConfig(*cfg)
}
// ToggleBlockInbound toggles the block inbound setting.
func (s *SettingsService) ToggleBlockInbound(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.BlockInbound = enabled
return s.SetConfig(*cfg)
}
// ToggleNotifications toggles the notifications setting.
func (s *SettingsService) ToggleNotifications(enabled bool) error {
cfg, err := s.GetConfig()
if err != nil {
return err
}
cfg.DisableNotifications = !enabled
return s.SetConfig(*cfg)
}

View File

@@ -0,0 +1,56 @@
//go:build !(linux && 386)
package services
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// UpdateService exposes update triggering and result polling to the Wails frontend.
type UpdateService struct {
grpcClient GRPCClientIface
}
// NewUpdateService creates a new UpdateService.
func NewUpdateService(g GRPCClientIface) *UpdateService {
return &UpdateService{grpcClient: g}
}
// InstallerResult holds the result of an installer run.
type InstallerResult struct {
Success bool `json:"success"`
ErrorMsg string `json:"errorMsg"`
}
// TriggerUpdate requests the daemon to perform an auto-update.
func (s *UpdateService) TriggerUpdate() error {
return nil
}
// GetInstallerResult polls for the installer result (blocking until complete or timeout).
func (s *UpdateService) GetInstallerResult() (*InstallerResult, error) {
conn, err := s.grpcClient.GetClient(3 * time.Second)
if err != nil {
return nil, fmt.Errorf("get client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
resp, err := conn.GetInstallerResult(ctx, &proto.InstallerResultRequest{})
if err != nil {
log.Infof("GetInstallerResult ended (daemon may have restarted): %v", err)
return &InstallerResult{Success: true}, nil
}
return &InstallerResult{
Success: resp.Success,
ErrorMsg: resp.ErrorMsg,
}, nil
}

View File

@@ -0,0 +1,40 @@
//go:build !windows && !(linux && 386)
package main
import (
"context"
"os"
"os/signal"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
)
// setupSignalHandler listens for SIGUSR1 and shows the main window when received.
func setupSignalHandler(ctx context.Context, window *application.WebviewWindow) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGUSR1)
go func() {
for {
select {
case <-ctx.Done():
return
case <-sigChan:
log.Info("received SIGUSR1 signal, showing window")
window.Show()
}
}
}()
}
// sendShowWindowSignal sends SIGUSR1 to an already-running instance to trigger window show.
func sendShowWindowSignal(pid int32) error {
proc, err := os.FindProcess(int(pid))
if err != nil {
return err
}
return proc.Signal(syscall.SIGUSR1)
}

View File

@@ -0,0 +1,84 @@
//go:build windows
package main
import (
"context"
"errors"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sys/windows"
)
const (
fancyUITriggerEventName = `Global\NetBirdFancyUITriggerEvent`
waitTimeout = 5 * time.Second
desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE
)
// setupSignalHandler sets up a Windows Event-based signal handler.
// When triggered, it shows the main window.
func setupSignalHandler(ctx context.Context, window *application.WebviewWindow) {
eventNamePtr, err := windows.UTF16PtrFromString(fancyUITriggerEventName)
if err != nil {
log.Errorf("convert event name to UTF16: %v", err)
return
}
eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr)
if err != nil {
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr)
if err != nil {
log.Errorf("open existing trigger event: %v", err)
return
}
} else {
log.Errorf("create trigger event: %v", err)
return
}
}
if eventHandle == windows.InvalidHandle {
log.Errorf("invalid handle for trigger event")
return
}
go waitForWindowsEvent(ctx, eventHandle, window)
}
func waitForWindowsEvent(ctx context.Context, eventHandle windows.Handle, window *application.WebviewWindow) {
defer func() {
if err := windows.CloseHandle(eventHandle); err != nil {
log.Errorf("close event handle: %v", err)
}
}()
for {
if ctx.Err() != nil {
return
}
status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds()))
switch status {
case windows.WAIT_OBJECT_0:
log.Info("received trigger event signal, showing window")
if err := windows.ResetEvent(eventHandle); err != nil {
log.Errorf("reset event: %v", err)
}
window.Show()
case uint32(windows.WAIT_TIMEOUT):
// Timeout is expected — loop and poll again.
default:
log.Errorf("unexpected WaitForSingleObject status %d: %v", status, err)
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
}
}
}

429
client/uiwails/tray.go Normal file
View File

@@ -0,0 +1,429 @@
//go:build !(linux && 386)
package main
import (
"context"
"fmt"
"sort"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/uiwails/services"
)
const statusPollInterval = 5 * time.Second
// trayManager manages the system tray state and menu.
type trayManager struct {
app *application.App
window *application.WebviewWindow
tray *application.SystemTray
menu *application.Menu
connSvc *services.ConnectionService
settingsSvc *services.SettingsService
networkSvc *services.NetworkService
profileSvc *services.ProfileService
mu sync.Mutex
statusItem *application.MenuItem
exitNodeMenu *application.Menu
// toggle items tracked for updating checked state
sshItem *application.MenuItem
autoConnectItem *application.MenuItem
rosenpassItem *application.MenuItem
lazyConnItem *application.MenuItem
blockInboundItem *application.MenuItem
notificationsItem *application.MenuItem
exitNodeItems []*application.MenuItem
exitNodeStates []exitNodeState
}
type exitNodeState struct {
id string
selected bool
}
func newTrayManager(
app *application.App,
window *application.WebviewWindow,
connSvc *services.ConnectionService,
settingsSvc *services.SettingsService,
networkSvc *services.NetworkService,
profileSvc *services.ProfileService,
) *trayManager {
return &trayManager{
app: app,
window: window,
connSvc: connSvc,
settingsSvc: settingsSvc,
networkSvc: networkSvc,
profileSvc: profileSvc,
}
}
// Setup creates and attaches the system tray.
func (t *trayManager) Setup(icon []byte) {
t.tray = t.app.SystemTray.New()
t.tray.SetIcon(icon)
t.menu = t.buildMenu()
t.tray.AttachWindow(t.window).WindowOffset(5).SetMenu(t.menu)
// Load initial toggle states from config.
go t.refreshToggleStates()
// Start status polling goroutine.
go t.pollStatus(context.Background())
}
func (t *trayManager) buildMenu() *application.Menu {
menu := t.app.NewMenu()
// Status label (disabled, informational).
t.statusItem = menu.Add("Status: Disconnected")
t.statusItem.SetEnabled(false)
menu.AddSeparator()
// Connect / Disconnect.
menu.Add("Connect").OnClick(func(_ *application.Context) {
go func() {
if err := t.connSvc.Connect(); err != nil {
log.Errorf("connect: %v", err)
}
}()
})
menu.Add("Disconnect").OnClick(func(_ *application.Context) {
go func() {
if err := t.connSvc.Disconnect(); err != nil {
log.Errorf("disconnect: %v", err)
}
}()
})
menu.AddSeparator()
// Toggle checkboxes.
t.sshItem = menu.AddCheckbox("Allow SSH connections", false)
t.sshItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleSSH(enabled); err != nil {
log.Errorf("toggle SSH: %v", err)
t.sshItem.SetChecked(!enabled)
}
}()
})
t.autoConnectItem = menu.AddCheckbox("Connect automatically when service starts", false)
t.autoConnectItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleAutoConnect(enabled); err != nil {
log.Errorf("toggle auto-connect: %v", err)
t.autoConnectItem.SetChecked(!enabled)
}
}()
})
t.rosenpassItem = menu.AddCheckbox("Enable post-quantum security via Rosenpass", false)
t.rosenpassItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleRosenpass(enabled); err != nil {
log.Errorf("toggle Rosenpass: %v", err)
t.rosenpassItem.SetChecked(!enabled)
}
}()
})
t.lazyConnItem = menu.AddCheckbox("[Experimental] Enable lazy connections", false)
t.lazyConnItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleLazyConn(enabled); err != nil {
log.Errorf("toggle lazy connections: %v", err)
t.lazyConnItem.SetChecked(!enabled)
}
}()
})
t.blockInboundItem = menu.AddCheckbox("Block inbound connections", false)
t.blockInboundItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleBlockInbound(enabled); err != nil {
log.Errorf("toggle block inbound: %v", err)
t.blockInboundItem.SetChecked(!enabled)
}
}()
})
t.notificationsItem = menu.AddCheckbox("Enable notifications", true)
t.notificationsItem.OnClick(func(ctx *application.Context) {
enabled := ctx.ClickedMenuItem().Checked()
go func() {
if err := t.settingsSvc.ToggleNotifications(enabled); err != nil {
log.Errorf("toggle notifications: %v", err)
t.notificationsItem.SetChecked(!enabled)
}
}()
})
menu.AddSeparator()
// Exit Node submenu.
t.exitNodeMenu = menu.AddSubmenu("Exit Node")
t.exitNodeMenu.Add("No exit nodes").SetEnabled(false)
menu.AddSeparator()
// Navigation items — navigate React SPA.
menu.Add("Status").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/")
t.window.Show()
})
menu.Add("Settings").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/settings")
t.window.Show()
})
menu.Add("Peers").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/peers")
t.window.Show()
})
menu.Add("Networks").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/networks")
t.window.Show()
})
menu.Add("Profiles").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/profiles")
t.window.Show()
})
menu.Add("Debug").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/debug")
t.window.Show()
})
menu.Add("Update").OnClick(func(_ *application.Context) {
t.window.EmitEvent("navigate", "/update")
t.window.Show()
})
menu.AddSeparator()
menu.Add("Quit").OnClick(func(_ *application.Context) {
t.app.Quit()
})
return menu
}
// pollStatus polls the daemon status every statusPollInterval and updates the tray.
// Exit nodes are refreshed every 10 cycles (~20 seconds).
func (t *trayManager) pollStatus(ctx context.Context) {
ticker := time.NewTicker(statusPollInterval)
defer ticker.Stop()
var cycle int
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
status, err := t.connSvc.GetStatus()
if err != nil {
log.Warnf("pollStatus: failed to get status: %v", err)
continue
}
log.Debugf("pollStatus: status=%q ip=%q fqdn=%q peers=%d",
status.Status, status.IP, status.Fqdn, status.ConnectedPeers)
t.updateStatus(status)
cycle++
if cycle%10 == 0 {
go t.refreshExitNodes()
}
}
}
}
func (t *trayManager) updateStatus(status *services.StatusInfo) {
label := fmt.Sprintf("Status: %s", status.Status)
if status.IP != "" {
label += fmt.Sprintf(" (%s)", status.IP)
}
t.statusItem.SetLabel(label)
t.menu.Update()
// Update tray icon based on status.
icon := iconForStatus(status.Status)
if icon != nil {
t.tray.SetIcon(icon)
}
// Emit event so the React frontend can update live.
log.Debugf("updateStatus: emitting status-changed event: status=%q ip=%q", status.Status, status.IP)
t.window.EmitEvent("status-changed", status)
}
func (t *trayManager) refreshToggleStates() {
cfg, err := t.settingsSvc.GetConfig()
if err != nil {
log.Debugf("refresh toggle states: %v", err)
return
}
t.sshItem.SetChecked(cfg.ServerSSHAllowed)
t.autoConnectItem.SetChecked(!cfg.DisableAutoConnect)
t.rosenpassItem.SetChecked(cfg.RosenpassEnabled)
t.lazyConnItem.SetChecked(cfg.LazyConnectionEnabled)
t.blockInboundItem.SetChecked(cfg.BlockInbound)
t.notificationsItem.SetChecked(!cfg.DisableNotifications)
t.menu.Update()
}
func (t *trayManager) refreshExitNodes() {
exitNodes, err := t.networkSvc.ListExitNodes()
if err != nil {
log.Debugf("refresh exit nodes: %v", err)
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.rebuildExitNodeMenu(exitNodes)
}
func (t *trayManager) rebuildExitNodeMenu(exitNodes []services.NetworkInfo) {
// Sort exit nodes by ID for stable ordering.
sort.Slice(exitNodes, func(i, j int) bool {
return exitNodes[i].ID < exitNodes[j].ID
})
// Check if state has changed.
newStates := make([]exitNodeState, 0, len(exitNodes))
for _, n := range exitNodes {
newStates = append(newStates, exitNodeState{id: n.ID, selected: n.Selected})
}
if statesEqual(t.exitNodeStates, newStates) {
return
}
t.exitNodeStates = newStates
// Rebuild the exit node submenu from scratch.
// Wails v3 doesn't have a RemoveAll, so we recreate the submenu reference.
for _, item := range t.exitNodeItems {
item.SetHidden(true)
}
t.exitNodeItems = nil
if len(exitNodes) == 0 {
t.menu.Update()
return
}
var hasSelected bool
for _, node := range exitNodes {
n := node // capture
item := t.exitNodeMenu.AddCheckbox(n.ID, n.Selected)
item.OnClick(func(_ *application.Context) {
go t.toggleExitNode(n.ID)
})
t.exitNodeItems = append(t.exitNodeItems, item)
if n.Selected {
hasSelected = true
}
}
if hasSelected {
t.exitNodeMenu.AddSeparator()
deselectAll := t.exitNodeMenu.Add("Deselect All")
deselectAll.OnClick(func(_ *application.Context) {
go t.deselectAllExitNodes()
})
t.exitNodeItems = append(t.exitNodeItems, deselectAll)
}
t.menu.Update()
}
func (t *trayManager) toggleExitNode(id string) {
exitNodes, err := t.networkSvc.ListExitNodes()
if err != nil {
log.Errorf("list exit nodes: %v", err)
return
}
var target *services.NetworkInfo
var selectedOtherIDs []string
for i, n := range exitNodes {
if n.ID == id {
cp := exitNodes[i]
target = &cp
} else if n.Selected {
selectedOtherIDs = append(selectedOtherIDs, n.ID)
}
}
// Deselect all other selected exit nodes.
if len(selectedOtherIDs) > 0 {
if err := t.networkSvc.DeselectNetworks(selectedOtherIDs); err != nil {
log.Errorf("deselect exit nodes: %v", err)
}
}
if target != nil && !target.Selected {
if err := t.networkSvc.SelectNetwork(id); err != nil {
log.Errorf("select exit node: %v", err)
}
} else if target != nil && target.Selected && len(selectedOtherIDs) == 0 {
// Node is the only selected one — deselect it.
if err := t.networkSvc.DeselectNetwork(id); err != nil {
log.Errorf("deselect exit node: %v", err)
}
}
t.refreshExitNodes()
}
func (t *trayManager) deselectAllExitNodes() {
exitNodes, err := t.networkSvc.ListExitNodes()
if err != nil {
log.Errorf("list exit nodes for deselect all: %v", err)
return
}
var ids []string
for _, n := range exitNodes {
if n.Selected {
ids = append(ids, n.ID)
}
}
if len(ids) > 0 {
if err := t.networkSvc.DeselectNetworks(ids); err != nil {
log.Errorf("deselect all exit nodes: %v", err)
}
}
t.refreshExitNodes()
}
func statesEqual(a, b []exitNodeState) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,24 @@
//go:build linux && !386
package main
import "os"
// init runs before Wails' own init(), so the env var is set in time.
func init() {
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" {
return
}
// WebKitGTK's DMA-BUF renderer fails on many setups (VMs, containers,
// minimal WMs without proper GPU access) and leaves the window blank
// white. Wails only disables it for NVIDIA+Wayland, but the issue is
// broader. Always disable it — software rendering works fine for a
// small UI like this.
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
// On Linux, the system tray provider may require the menu to be recreated
// rather than updated in place. The rebuildExitNodeMenu method in tray.go
// already handles this by removing and re-adding items; no additional
// Linux-specific workaround is needed for Wails v3.

View File

@@ -0,0 +1,148 @@
//go:build linux && !(linux && 386)
package main
// startStatusNotifierWatcher registers org.kde.StatusNotifierWatcher on the
// session D-Bus if no other process has already claimed it.
//
// Minimal window managers (Fluxbox, OpenBox, i3, etc.) do not ship a
// StatusNotifier watcher, so tray icons using libayatana-appindicator or
// the KDE/freedesktop StatusNotifier protocol silently fail.
//
// By owning the watcher name in-process we allow the Wails v3 built-in tray
// to register itself — no external daemon or package needed.
//
// When an XEmbed system tray is available (_NET_SYSTEM_TRAY_S0), we also
// start an in-process XEmbed host that bridges the SNI icon into the
// XEmbed tray (Fluxbox, IceWM, etc.).
import (
"sync"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
watcherName = "org.kde.StatusNotifierWatcher"
watcherPath = "/StatusNotifierWatcher"
watcherIface = "org.kde.StatusNotifierWatcher"
)
type statusNotifierWatcher struct {
conn *dbus.Conn
items []string
hosts map[string]*xembedHost
hostsMu sync.Mutex
}
// RegisterStatusNotifierItem is the D-Bus method called by tray clients.
// The sender parameter is automatically injected by godbus with the caller's
// unique bus name (e.g. ":1.42"). It does not appear in the D-Bus signature.
func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
for _, s := range w.items {
if s == service {
return nil
}
}
w.items = append(w.items, service)
log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender)
go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service))
return nil
}
// RegisterStatusNotifierHost is required by the protocol but unused here.
func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error {
log.Debugf("StatusNotifierWatcher: host registered %q", service)
return nil
}
// tryStartXembedHost attempts to create an XEmbed tray icon for the given
// SNI item. If no XEmbed tray manager is available, this is a no-op.
func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) {
w.hostsMu.Lock()
defer w.hostsMu.Unlock()
if _, exists := w.hosts[busName]; exists {
return
}
// Use a private session bus so our signal subscriptions don't
// interfere with Wails' signal handler (which panics on unexpected signals).
sessionConn, err := dbus.SessionBusPrivate()
if err != nil {
log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err)
return
}
if err := sessionConn.Auth(nil); err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err)
_ = sessionConn.Close()
return
}
if err := sessionConn.Hello(); err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err)
_ = sessionConn.Close()
return
}
host, err := newXembedHost(sessionConn, busName, objPath)
if err != nil {
log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err)
return
}
w.hosts[busName] = host
go host.run()
log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName)
}
// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the
// session bus if it is not already provided by another process.
// Safe to call unconditionally — it does nothing when a real watcher is present.
func startStatusNotifierWatcher() {
conn, err := dbus.SessionBusPrivate()
if err != nil {
log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err)
return
}
if err := conn.Auth(nil); err != nil {
log.Debugf("StatusNotifierWatcher: auth failed: %v", err)
_ = conn.Close()
return
}
if err := conn.Hello(); err != nil {
log.Debugf("StatusNotifierWatcher: Hello failed: %v", err)
_ = conn.Close()
return
}
// Check whether another process already owns the watcher name.
var owner string
callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner)
if callErr == nil && owner != "" {
log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner)
_ = conn.Close()
return
}
reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue)
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err)
_ = conn.Close()
return
}
w := &statusNotifierWatcher{
conn: conn,
hosts: make(map[string]*xembedHost),
}
if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil {
log.Errorf("StatusNotifierWatcher: export failed: %v", err)
_ = conn.Close()
return
}
log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)")
// Connection intentionally kept open for the lifetime of the process.
}

View File

@@ -0,0 +1,6 @@
//go:build !linux || (linux && 386)
package main
// startStatusNotifierWatcher is a no-op on non-Linux platforms.
func startStatusNotifierWatcher() {}

BIN
client/uiwails/uiwails Executable file

Binary file not shown.

13
client/uiwails/util.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !(linux && 386)
package main
import (
"context"
"time"
)
// defaultContext returns a context with the given timeout.
func defaultContext(timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), timeout)
}

View File

@@ -0,0 +1,8 @@
{
"name": "netbird-ui",
"outputfilename": "netbird-ui",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverurl": "auto"
}

View File

@@ -0,0 +1,393 @@
//go:build linux && !(linux && 386)
package main
/*
#cgo pkg-config: x11 gtk+-3.0
#cgo LDFLAGS: -lX11
#include "xembed_tray.h"
#include <X11/Xlib.h>
#include <stdlib.h>
*/
import "C"
import (
"errors"
"sync"
"time"
"unsafe"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
// activeMenuHost is the xembedHost that currently owns the popup menu.
// This is needed because C callbacks cannot carry Go pointers.
var (
activeMenuHost *xembedHost
activeMenuHostMu sync.Mutex
)
//export goMenuItemClicked
func goMenuItemClicked(id C.int) {
activeMenuHostMu.Lock()
h := activeMenuHost
activeMenuHostMu.Unlock()
if h != nil {
go h.sendMenuEvent(int32(id))
}
}
// xembedHost manages one XEmbed tray icon for an SNI item.
type xembedHost struct {
conn *dbus.Conn
busName string
objPath dbus.ObjectPath
dpy *C.Display
trayMgr C.Window
iconWin C.Window
iconSize int
mu sync.Mutex
iconData []byte
iconW int
iconH int
stopCh chan struct{}
}
// newXembedHost creates an XEmbed tray icon for the given SNI item.
// Returns an error if no XEmbed tray manager is available (graceful fallback).
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
dpy := C.XOpenDisplay(nil)
if dpy == nil {
return nil, errors.New("cannot open X display")
}
screen := C.xembed_default_screen(dpy)
trayMgr := C.xembed_find_tray(dpy, screen)
if trayMgr == 0 {
C.XCloseDisplay(dpy)
return nil, errors.New("no XEmbed system tray found")
}
// Query the tray manager's preferred icon size.
iconSize := int(C.xembed_get_icon_size(dpy, trayMgr))
if iconSize <= 0 {
iconSize = 24 // fallback
}
iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr)
if iconWin == 0 {
C.XCloseDisplay(dpy)
return nil, errors.New("failed to create icon window")
}
if C.xembed_dock(dpy, trayMgr, iconWin) != 0 {
C.xembed_destroy_icon(dpy, iconWin)
C.XCloseDisplay(dpy)
return nil, errors.New("failed to dock icon")
}
h := &xembedHost{
conn: conn,
busName: busName,
objPath: objPath,
dpy: dpy,
trayMgr: trayMgr,
iconWin: iconWin,
iconSize: iconSize,
stopCh: make(chan struct{}),
}
h.fetchAndDrawIcon()
return h, nil
}
// fetchAndDrawIcon reads IconPixmap from the SNI item via D-Bus and draws it.
func (h *xembedHost) fetchAndDrawIcon() {
obj := h.conn.Object(h.busName, h.objPath)
variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap")
if err != nil {
log.Debugf("xembed: failed to get IconPixmap: %v", err)
return
}
// IconPixmap is []struct{W, H int32; Pix []byte} on D-Bus,
// represented as a(iiay) signature.
type px struct {
W int32
H int32
Pix []byte
}
var icons []px
if err := variant.Store(&icons); err != nil {
log.Debugf("xembed: failed to decode IconPixmap: %v", err)
return
}
if len(icons) == 0 {
log.Debug("xembed: IconPixmap is empty")
return
}
icon := icons[0]
if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) {
log.Debug("xembed: invalid IconPixmap data")
return
}
h.mu.Lock()
h.iconData = icon.Pix
h.iconW = int(icon.W)
h.iconH = int(icon.H)
h.mu.Unlock()
h.drawIcon()
}
// drawIcon draws the cached icon data onto the X11 window.
func (h *xembedHost) drawIcon() {
h.mu.Lock()
data := h.iconData
w := h.iconW
ht := h.iconH
h.mu.Unlock()
if data == nil || w <= 0 || ht <= 0 {
return
}
cData := C.CBytes(data)
defer C.free(cData)
C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize),
(*C.uchar)(cData), C.int(w), C.int(ht))
}
// run is the main event loop. It polls X11 events and listens for D-Bus
// NewIcon signals to keep the tray icon updated.
func (h *xembedHost) run() {
// Subscribe to NewIcon signals from the SNI item.
matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'"
if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
log.Debugf("xembed: failed to add signal match: %v", err)
}
sigCh := make(chan *dbus.Signal, 16)
h.conn.Signal(sigCh)
defer h.conn.RemoveSignal(sigCh)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-h.stopCh:
return
case sig := <-sigCh:
if sig == nil {
continue
}
if sig.Name == "org.kde.StatusNotifierItem.NewIcon" {
h.fetchAndDrawIcon()
}
case <-ticker.C:
var outX, outY C.int
result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY)
switch result {
case 1: // left click
go h.activate(int32(outX), int32(outY))
case 2: // right click
log.Infof("xembed: right-click at (%d, %d)", int(outX), int(outY))
go h.contextMenu(int32(outX), int32(outY))
case 3: // expose
h.drawIcon()
case 4: // configure (resize)
newSize := int(outX)
if newSize > 0 && newSize != h.iconSize {
h.iconSize = newSize
h.drawIcon()
}
case -1: // tray died
log.Info("xembed: tray manager destroyed, cleaning up")
return
}
}
}
}
func (h *xembedHost) activate(x, y int32) {
obj := h.conn.Object(h.busName, h.objPath)
if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil {
log.Debugf("xembed: Activate call failed: %v", err)
}
}
func (h *xembedHost) contextMenu(x, y int32) {
// Read the menu path from the SNI item's Menu property.
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
// Fetch menu layout from com.canonical.dbusmenu.
menuObj := h.conn.Object(h.busName, menuPath)
var revision uint32
var layout dbusMenuLayout
err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0,
int32(0), // parentId (root)
int32(-1), // recursionDepth (all)
[]string{}, // propertyNames (all)
).Store(&revision, &layout)
if err != nil {
log.Debugf("xembed: GetLayout failed: %v", err)
return
}
items := h.flattenMenu(layout)
log.Infof("xembed: menu has %d items (revision %d)", len(items), revision)
for i, mi := range items {
log.Infof("xembed: menu[%d] id=%d label=%q sep=%v check=%v", i, mi.id, mi.label, mi.isSeparator, mi.isCheck)
}
if len(items) == 0 {
return
}
// Build C menu item array.
cItems := make([]C.xembed_menu_item, len(items))
cLabels := make([]*C.char, len(items)) // track for freeing
for i, mi := range items {
cItems[i].id = C.int(mi.id)
cItems[i].enabled = boolToInt(mi.enabled)
cItems[i].is_check = boolToInt(mi.isCheck)
cItems[i].checked = boolToInt(mi.checked)
cItems[i].is_separator = boolToInt(mi.isSeparator)
if mi.label != "" {
cLabels[i] = C.CString(mi.label)
cItems[i].label = cLabels[i]
}
}
defer func() {
for _, p := range cLabels {
if p != nil {
C.free(unsafe.Pointer(p))
}
}
}()
// Set the active menu host so the C callback can reach us.
activeMenuHostMu.Lock()
activeMenuHost = h
activeMenuHostMu.Unlock()
C.xembed_show_popup_menu(&cItems[0], C.int(len(cItems)),
nil, C.int(x), C.int(y))
}
// dbusMenuLayout represents a com.canonical.dbusmenu layout item.
type dbusMenuLayout struct {
ID int32
Properties map[string]dbus.Variant
Children []dbus.Variant
}
type menuItemInfo struct {
id int32
label string
enabled bool
isCheck bool
checked bool
isSeparator bool
}
func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo {
var items []menuItemInfo
for _, childVar := range layout.Children {
var child dbusMenuLayout
if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil {
continue
}
mi := menuItemInfo{
id: child.ID,
enabled: true,
}
if v, ok := child.Properties["type"]; ok {
if s, ok := v.Value().(string); ok && s == "separator" {
mi.isSeparator = true
items = append(items, mi)
continue
}
}
if v, ok := child.Properties["label"]; ok {
if s, ok := v.Value().(string); ok {
mi.label = s
}
}
if v, ok := child.Properties["enabled"]; ok {
if b, ok := v.Value().(bool); ok {
mi.enabled = b
}
}
if v, ok := child.Properties["visible"]; ok {
if b, ok := v.Value().(bool); ok && !b {
continue // skip hidden items
}
}
if v, ok := child.Properties["toggle-type"]; ok {
if s, ok := v.Value().(string); ok && s == "checkmark" {
mi.isCheck = true
}
}
if v, ok := child.Properties["toggle-state"]; ok {
if n, ok := v.Value().(int32); ok && n == 1 {
mi.checked = true
}
}
items = append(items, mi)
}
return items
}
func (h *xembedHost) sendMenuEvent(id int32) {
menuPath := dbus.ObjectPath("/StatusNotifierMenu")
menuObj := h.conn.Object(h.busName, menuPath)
data := dbus.MakeVariant("")
err := menuObj.Call("com.canonical.dbusmenu.Event", 0,
id, "clicked", data, uint32(0)).Err
if err != nil {
log.Debugf("xembed: menu Event call failed: %v", err)
}
}
func boolToInt(b bool) C.int {
if b {
return 1
}
return 0
}
func (h *xembedHost) stop() {
select {
case <-h.stopCh:
return // already stopped
default:
close(h.stopCh)
}
C.xembed_destroy_icon(h.dpy, h.iconWin)
C.XCloseDisplay(h.dpy)
}

View File

@@ -0,0 +1,18 @@
//go:build !linux || (linux && 386)
package main
import (
"errors"
"github.com/godbus/dbus/v5"
)
type xembedHost struct{}
func newXembedHost(_ *dbus.Conn, _ string, _ dbus.ObjectPath) (*xembedHost, error) {
return nil, errors.New("XEmbed tray not supported on this platform")
}
func (h *xembedHost) run() {}
func (h *xembedHost) stop() {}

View File

@@ -0,0 +1,428 @@
#include "xembed_tray.h"
#include <X11/Xatom.h>
#include <X11/Xutil.h>
#include <gtk/gtk.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#define SYSTEM_TRAY_REQUEST_DOCK 0
#define XEMBED_MAPPED (1 << 0)
Window xembed_find_tray(Display *dpy, int screen) {
char atom_name[64];
snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen);
Atom sel = XInternAtom(dpy, atom_name, False);
return XGetSelectionOwner(dpy, sel);
}
int xembed_get_icon_size(Display *dpy, Window tray_mgr) {
Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False);
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
unsigned char *prop = NULL;
int size = 0;
if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False,
XA_CARDINAL, &actual_type, &actual_format,
&nitems, &bytes_after, &prop) == Success) {
if (prop && nitems == 1 && actual_format == 32) {
size = (int)(*(unsigned long *)prop);
}
if (prop)
XFree(prop);
}
return size;
}
/* Find a 32-bit TrueColor ARGB visual on the given screen. */
static Visual *find_argb_visual(Display *dpy, int screen, int *out_depth) {
XVisualInfo tmpl;
tmpl.screen = screen;
tmpl.depth = 32;
tmpl.class = TrueColor;
int ninfo = 0;
XVisualInfo *vi = XGetVisualInfo(dpy,
VisualScreenMask | VisualDepthMask | VisualClassMask,
&tmpl, &ninfo);
if (!vi || ninfo == 0)
return NULL;
Visual *vis = vi[0].visual;
*out_depth = vi[0].depth;
XFree(vi);
return vis;
}
/* Try to get a 32-bit ARGB visual for the tray icon.
First checks _NET_SYSTEM_TRAY_VISUAL on the tray manager window;
if not set, searches for any 32-bit TrueColor visual on the screen. */
static Visual *get_tray_visual(Display *dpy, int screen, Window tray_mgr,
int *out_depth) {
Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_VISUAL", False);
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
unsigned char *prop = NULL;
if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False,
XA_VISUALID, &actual_type, &actual_format,
&nitems, &bytes_after, &prop) == Success &&
prop && nitems == 1) {
VisualID vid = (VisualID)(*(unsigned long *)prop);
XFree(prop);
/* Look up the visual by ID. */
XVisualInfo tmpl;
tmpl.visualid = vid;
tmpl.screen = screen;
int ninfo = 0;
XVisualInfo *vi = XGetVisualInfo(dpy,
VisualIDMask | VisualScreenMask, &tmpl, &ninfo);
if (vi && ninfo > 0) {
Visual *vis = vi[0].visual;
*out_depth = vi[0].depth;
XFree(vi);
return vis;
}
} else {
if (prop) XFree(prop);
}
/* Tray didn't advertise a visual — find one ourselves. */
return find_argb_visual(dpy, screen, out_depth);
}
Window xembed_create_icon(Display *dpy, int screen, int size,
Window tray_mgr) {
Window root = RootWindow(dpy, screen);
/* Try to use the tray's advertised ARGB visual for transparency. */
int depth = 0;
Visual *vis = get_tray_visual(dpy, screen, tray_mgr, &depth);
XSetWindowAttributes attrs;
memset(&attrs, 0, sizeof(attrs));
attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask;
unsigned long mask = CWEventMask;
if (vis && depth == 32) {
/* 32-bit visual: create our own colormap and set a transparent bg. */
attrs.colormap = XCreateColormap(dpy, root, vis, AllocNone);
attrs.background_pixel = 0; /* fully transparent */
attrs.border_pixel = 0;
mask |= CWColormap | CWBackPixel | CWBorderPixel;
} else {
/* Fallback: use default visual. */
vis = CopyFromParent;
depth = CopyFromParent;
attrs.background_pixel = 0;
mask |= CWBackPixel;
}
Window win = XCreateWindow(
dpy, root,
0, 0, size, size,
0, /* border width */
depth,
InputOutput,
vis,
mask,
&attrs
);
/* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */
Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False);
unsigned long info[2] = { 0, XEMBED_MAPPED };
XChangeProperty(dpy, win, xembed_info, xembed_info,
32, PropModeReplace, (unsigned char *)info, 2);
return win;
}
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) {
Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False);
XClientMessageEvent ev;
memset(&ev, 0, sizeof(ev));
ev.type = ClientMessage;
ev.window = tray_mgr;
ev.message_type = opcode;
ev.format = 32;
ev.data.l[0] = CurrentTime;
ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK;
ev.data.l[2] = (long)icon_win;
XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev);
XFlush(dpy);
return 0;
}
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
const unsigned char *data, int img_w, int img_h) {
if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0)
return;
/* Query the window's actual visual and depth so we draw correctly
even when using a 32-bit ARGB visual for transparency. */
XWindowAttributes wa;
if (!XGetWindowAttributes(dpy, icon_win, &wa))
return;
int depth = wa.depth;
Visual *vis = wa.visual;
/* Clear the window to transparent before drawing. */
XClearWindow(dpy, icon_win);
/* Allocate buffer for the scaled image in native X11 format (32bpp). */
unsigned int *buf = (unsigned int *)calloc(win_size * win_size,
sizeof(unsigned int));
if (!buf)
return;
/* Nearest-neighbor scale from source ARGB [A,R,G,B] bytes to native uint32. */
int x, y;
for (y = 0; y < win_size; y++) {
int src_y = y * img_h / win_size;
if (src_y >= img_h) src_y = img_h - 1;
for (x = 0; x < win_size; x++) {
int src_x = x * img_w / win_size;
if (src_x >= img_w) src_x = img_w - 1;
int idx = (src_y * img_w + src_x) * 4;
unsigned char a = data[idx + 0];
unsigned char r = data[idx + 1];
unsigned char g = data[idx + 2];
unsigned char b = data[idx + 3];
/* Pre-multiply alpha for correct compositing on 32-bit visuals. */
if (a == 0) {
buf[y * win_size + x] = 0;
} else if (a == 255) {
buf[y * win_size + x] = ((unsigned int)a << 24) |
((unsigned int)r << 16) |
((unsigned int)g << 8) |
((unsigned int)b);
} else {
unsigned int pr = (unsigned int)r * a / 255;
unsigned int pg = (unsigned int)g * a / 255;
unsigned int pb = (unsigned int)b * a / 255;
buf[y * win_size + x] = ((unsigned int)a << 24) |
(pr << 16) | (pg << 8) | pb;
}
}
}
XImage *img = XCreateImage(dpy, vis, depth, ZPixmap, 0,
(char *)buf, win_size, win_size,
32, 0);
if (!img) {
free(buf);
return;
}
GC gc = XCreateGC(dpy, icon_win, 0, NULL);
XPutImage(dpy, icon_win, gc, img, 0, 0, 0, 0, win_size, win_size);
XFreeGC(dpy, gc);
/* XDestroyImage frees the data pointer (buf) for us. */
XDestroyImage(img);
XFlush(dpy);
}
void xembed_destroy_icon(Display *dpy, Window icon_win) {
if (icon_win)
XDestroyWindow(dpy, icon_win);
XFlush(dpy);
}
int xembed_poll_event(Display *dpy, Window icon_win,
int *out_x, int *out_y) {
*out_x = 0;
*out_y = 0;
while (XPending(dpy) > 0) {
XEvent ev;
XNextEvent(dpy, &ev);
switch (ev.type) {
case ButtonPress:
if (ev.xbutton.window == icon_win) {
*out_x = ev.xbutton.x_root;
*out_y = ev.xbutton.y_root;
if (ev.xbutton.button == Button1)
return 1;
if (ev.xbutton.button == Button3)
return 2;
}
break;
case Expose:
if (ev.xexpose.window == icon_win && ev.xexpose.count == 0)
return 3;
break;
case DestroyNotify:
if (ev.xdestroywindow.window == icon_win)
return -1;
break;
case ConfigureNotify:
if (ev.xconfigure.window == icon_win) {
*out_x = ev.xconfigure.width;
*out_y = ev.xconfigure.height;
return 4;
}
break;
case ReparentNotify:
/* Tray manager reparented us — this is expected after docking. */
break;
default:
break;
}
}
return 0;
}
/* --- GTK3 popup window menu support --- */
/* Implemented in Go via //export */
extern void goMenuItemClicked(int id);
/* The popup window, reused across invocations. */
static GtkWidget *popup_win = NULL;
typedef struct {
xembed_menu_item *items;
int count;
int x, y;
} popup_data;
static void free_popup_data(popup_data *pd) {
if (!pd) return;
for (int i = 0; i < pd->count; i++)
free((void *)pd->items[i].label);
free(pd->items);
free(pd);
}
static void on_button_clicked(GtkButton *btn, gpointer user_data) {
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
gtk_widget_hide(popup_win);
goMenuItemClicked(id);
}
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
int id = GPOINTER_TO_INT(user_data);
if (popup_win)
gtk_widget_hide(popup_win);
goMenuItemClicked(id);
}
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
gpointer user_data) {
gtk_widget_hide(widget);
return FALSE;
}
static gboolean popup_menu_idle(gpointer user_data) {
popup_data *pd = (popup_data *)user_data;
/* Destroy old popup if it exists. */
if (popup_win) {
gtk_widget_destroy(popup_win);
popup_win = NULL;
}
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
/* Close on focus loss. */
g_signal_connect(popup_win, "focus-out-event",
G_CALLBACK(on_popup_focus_out), NULL);
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
for (int i = 0; i < pd->count; i++) {
xembed_menu_item *mi = &pd->items[i];
if (mi->is_separator) {
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
continue;
}
if (mi->is_check) {
GtkWidget *chk = gtk_check_button_new_with_label(
mi->label ? mi->label : "");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
gtk_widget_set_sensitive(chk, mi->enabled);
g_signal_connect(chk, "toggled",
G_CALLBACK(on_check_toggled),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
} else {
GtkWidget *btn = gtk_button_new_with_label(
mi->label ? mi->label : "");
gtk_widget_set_sensitive(btn, mi->enabled);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
/* Left-align label. */
GtkWidget *label = gtk_bin_get_child(GTK_BIN(btn));
if (label)
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
g_signal_connect(btn, "clicked",
G_CALLBACK(on_button_clicked),
GINT_TO_POINTER(mi->id));
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
}
}
gtk_widget_show_all(popup_win);
/* Position the window above the click point (menu grows upward from tray). */
gint win_w, win_h;
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
int final_x = pd->x - win_w / 2;
int final_y = pd->y - win_h;
if (final_x < 0) final_x = 0;
if (final_y < 0) final_y = pd->y; /* fallback: below click */
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
/* Grab focus so focus-out-event works. */
gtk_window_present(GTK_WINDOW(popup_win));
free_popup_data(pd);
return G_SOURCE_REMOVE;
}
void xembed_show_popup_menu(xembed_menu_item *items, int count,
xembed_menu_click_cb cb, int x, int y) {
(void)cb;
popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data));
pd->items = (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item));
pd->count = count;
pd->x = x;
pd->y = y;
for (int i = 0; i < count; i++) {
pd->items[i] = items[i];
if (items[i].label)
pd->items[i].label = strdup(items[i].label);
}
g_idle_add(popup_menu_idle, pd);
}

View File

@@ -0,0 +1,71 @@
#ifndef XEMBED_TRAY_H
#define XEMBED_TRAY_H
#include <X11/Xlib.h>
// xembed_default_screen wraps the DefaultScreen macro for CGo.
static inline int xembed_default_screen(Display *dpy) {
return DefaultScreen(dpy);
}
// xembed_find_tray returns the selection owner window for
// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists.
Window xembed_find_tray(Display *dpy, int screen);
// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray
// manager window. Returns the size in pixels, or 0 if not set.
int xembed_get_icon_size(Display *dpy, Window tray_mgr);
// xembed_create_icon creates a tray icon window of the given size,
// sets _XEMBED_INFO, and returns the window ID.
// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL
// property is queried to obtain a 32-bit ARGB visual for transparency.
Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr);
// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK
// to the tray manager to embed our icon window.
int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win);
// xembed_draw_icon draws ARGB pixel data onto the icon window.
// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format).
// img_w, img_h are the source image dimensions.
// win_size is the target window dimension (square).
void xembed_draw_icon(Display *dpy, Window icon_win, int win_size,
const unsigned char *data, int img_w, int img_h);
// xembed_destroy_icon destroys the icon window.
void xembed_destroy_icon(Display *dpy, Window icon_win);
// xembed_poll_event processes pending X11 events. Returns:
// 0 = no actionable event
// 1 = left button press (out_x, out_y filled)
// 2 = right button press (out_x, out_y filled)
// 3 = expose (needs redraw)
// 4 = configure (resize; out_x=width, out_y=height)
// -1 = DestroyNotify on icon window (tray died)
int xembed_poll_event(Display *dpy, Window icon_win,
int *out_x, int *out_y);
// Callback type for menu item clicks. Called with the item's dbusmenu ID.
typedef void (*xembed_menu_click_cb)(int id);
// xembed_popup_menu builds and shows a GTK3 popup menu.
// items is an array of menu item descriptors, count is the number of items.
// cb is called (from the GTK main thread) when an item is clicked.
// x, y are root coordinates for positioning the popup.
// This must be called from the GTK main thread (use g_idle_add).
typedef struct {
int id; // dbusmenu item ID
const char *label; // display label (NULL for separator)
int enabled; // whether the item is clickable
int is_check; // whether this is a checkbox item
int checked; // checkbox state (0 or 1)
int is_separator;// 1 if this is a separator
} xembed_menu_item;
// Schedule a GTK popup menu on the main thread.
void xembed_show_popup_menu(xembed_menu_item *items, int count,
xembed_menu_click_cb cb, int x, int y);
#endif

71
go.mod
View File

@@ -7,20 +7,20 @@ toolchain go1.25.5
require (
cunicu.li/go-rosenpass v0.4.0
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/golang/protobuf v1.5.4
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/kardianos/service v1.2.3-0.20240613133416-becf2eb62b83
github.com/onsi/ginkgo v1.16.5
github.com/onsi/gomega v1.27.6
github.com/onsi/gomega v1.34.1
github.com/rs/cors v1.8.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
github.com/spf13/pflag v1.0.10
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.46.0
golang.org/x/sys v0.39.0
golang.org/x/crypto v0.47.0
golang.org/x/sys v0.40.0
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
golang.zx2c4.com/wireguard/windows v0.5.3
@@ -51,7 +51,7 @@ require (
github.com/eko/gocache/store/redis/v4 v4.2.2
github.com/fsnotify/fsnotify v1.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/godbus/dbus/v5 v5.1.0
github.com/godbus/dbus/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.7.0
@@ -111,13 +111,13 @@ require (
go.uber.org/mock v0.5.2
go.uber.org/zap v1.27.0
goauthentik.io/api/v3 v3.2023051.3
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
golang.org/x/mod v0.30.0
golang.org/x/net v0.47.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.38.0
golang.org/x/term v0.39.0
golang.org/x/time v0.14.0
google.golang.org/api v0.257.0
gopkg.in/yaml.v3 v3.0.1
@@ -132,16 +132,18 @@ require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.1 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/awnumar/memcall v0.4.0 // indirect
@@ -163,18 +165,21 @@ require (
github.com/aws/smithy-go v1.22.2 // indirect
github.com/beevik/etree v1.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.0.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
@@ -182,6 +187,9 @@ require (
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -193,6 +201,7 @@ require (
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
@@ -207,6 +216,8 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -214,14 +225,20 @@ require (
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kelseyhightower/envconfig v1.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
@@ -240,7 +257,7 @@ require (
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/nxadm/tail v1.4.8 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pion/dtls/v2 v2.2.10 // indirect
@@ -248,18 +265,24 @@ require (
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/transport/v2 v2.2.4 // indirect
github.com/pion/turn/v4 v4.1.1 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russellhaering/goxmldsig v1.5.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/objx v0.5.2 // indirect
@@ -267,8 +290,11 @@ require (
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -276,13 +302,14 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502

104
go.sum
View File

@@ -9,6 +9,8 @@ cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw=
cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
@@ -25,17 +27,25 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo=
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -88,6 +98,8 @@ github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -122,6 +134,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0=
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -139,12 +153,16 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M=
github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw=
github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA=
github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0=
github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -173,6 +191,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
@@ -207,10 +231,14 @@ github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -293,6 +321,10 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -323,13 +355,18 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6U
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -341,6 +378,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -348,6 +389,8 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA=
github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU=
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
@@ -355,9 +398,14 @@ github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tA
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -429,10 +477,12 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -473,6 +523,10 @@ github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -497,6 +551,9 @@ github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9M
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
@@ -508,6 +565,10 @@ github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvD
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
@@ -518,18 +579,24 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
@@ -541,6 +608,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@@ -583,14 +651,22 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
@@ -641,6 +717,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -650,10 +727,16 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q=
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0=
@@ -668,6 +751,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -677,6 +762,7 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
@@ -688,6 +774,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@@ -711,14 +799,18 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -728,6 +820,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -740,6 +833,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -754,9 +849,12 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@@ -767,6 +865,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -782,6 +882,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -832,6 +934,8 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=