Files
netbird/client/ui/main.go
T
Zoltán Papp 2cdc6ef1c6 ui: split tray.go into feature files, rename Peers service to DaemonFeed
The 1542-line tray.go grew into a 14-feature kitchen sink. Split it
into feature-coherent same-package siblings, give the daemon-stream
service a name that matches what it actually does, and trim the
cargo-cult context.WithCancel pattern from click handlers.

File layout (tray.go: 1542 → ~470 lines):
  - tray_status.go    onStatusEvent / applyStatus / status indicator
  - tray_icon.go      applyIcon / iconForState (tray icon painting)
  - tray_events.go    onSystemEvent + eventTitle / titleCase, plus a
                      shouldSkipSystemEvent helper that names the
                      three "daemon notification we don't surface"
                      filters
  - tray_session.go   session-expiry row + warning notification flow +
                      handleSessionExpired (moved from tray.go)
  - tray_profiles.go  loadConfig / loadProfiles / switchProfile
  - tray_exitnodes.go exit-node submenu (rebuild / refresh / toggle)

Mutex split: the kitchen-sink t.mu becomes four domain-scoped mutexes
so a long-running gRPC call in one domain can't block status-push
readers in another:
  - statusMu        connected / lastStatus / lastDaemonVersion /
                    lastNetworksRevision / pendingConnectLogin
  - sessionMu       sessionExpiresAt (read by the 30s ticker,
                    written by applySessionExpiry on every status push)
  - profileMu       activeProfile / activeUsername /
                    notificationsEnabled / switchCancel
  - exitNodesMu     row cache (read in reapplyMenuState's Repaint copy)
  - exitNodesRebuildMu  serialises ListNetworks + submenu rebuild +
                        SetMenu (already separate, kept)

Service rename: the "Peers" service handled the daemon's full
SubscribeStatus snapshot (peers, daemon version, management/signal
link state, networks revision, SSO deadline) plus the SubscribeEvents
notification stream and the profile-switch suppression filter. Peers
was a misleading name for a daemon-stream fan-out service. Rename to
DaemonFeed in services/, profileswitcher's stored reference, the
TrayServices struct, main.go wiring, and every doc comment that
referenced it. peers.go → daemon_feed.go. The Status.Peers field
itself (the peer list in the snapshot) is unchanged.

Event constant renames (wire strings unchanged so the frontend keeps
working without regenerating bindings beyond the rename):
  - EventStatus → EventStatusSnapshot
    Payload is a full Status struct (daemon-wide snapshot), not just
    a state-change ping — name the value-shape.
  - EventSystem → EventDaemonNotification
    Payload is a daemon SystemEvent meant to drive an OS toast or a
    Recent Events row. "System" was too generic; "Notification"
    matches what consumers do with it.

Concurrency fixes:
  - WaitExtendAuthSession now preempts a previous in-flight wait
    via the existing SetWaitCancel/CancelWait infrastructure on
    PendingFlow, the same pattern WaitSSOLogin uses. The previous
    waiter exits with codes.Canceled; the authsession service
    translates that to ExtendResult{Preempted: true} so the tray
    and the about-to-expire dialog stay silent on the losing flow
    instead of showing a false-failure toast. Without this, both
    a tray "Extend now" click and a dialog "Stay connected" click
    on the same deadline started two parallel IdP polls, and
    whichever lost the device-code check painted a bogus error.
  - mgmClient.ExtendAuthSession drops the dead backoff retry loop.
    The loop only retried on codes.Canceled, but the inner mgmCtx
    was derived from context.Background() and never cancelled, so
    every real error went straight to backoff.Permanent on the
    first attempt. Replace with a single
    context.WithTimeout(c.ctx, ConnectTimeout) call; daemon
    shutdown now interrupts the RPC and behaviour on real errors
    is unchanged.

Click-handler hygiene: six call sites used the cargo-cult
context.WithCancel(context.Background()) + defer cancel() pattern
without ever calling cancel() externally. Replace with
context.Background() directly (loadConfig, loadProfiles,
runExtendSession, dismissSessionWarning, handleConnect's Up,
handleDisconnect's Down). The one site that genuinely needs the
cancel — switchProfile, which stores it in t.switchCancel so
handleDisconnect can preempt the switch — keeps WithCancel.

Helper extraction: shouldSkipSystemEvent groups the three
"daemon notification we drop on the floor" checks
(new_version_available metadata, progress_window metadata, the
::/0 partner of an exit-node default-route event) behind a single
named predicate. Each had a comment explaining why; collecting
them moves the rationale into the helper docstring and shrinks
onSystemEvent to a router.
2026-05-28 21:26:57 +02:00

254 lines
9.6 KiB
Go

//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"embed"
"flag"
"io/fs"
"log"
"runtime"
"strings"
"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/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/client/ui/updater"
"github.com/netbirdio/netbird/util"
)
//go:embed all:frontend/dist
var assets embed.FS
// localesFS roots the i18n translation bundles. Embedded from the same
// directory the React app imports, so a single JSON source drives both
// the tray (Go) and the in-window UI (Vite imports the files directly).
// The `all:` prefix is required so _index.json is included — //go:embed
// silently drops files whose names start with "_" or "." otherwise.
//
//go:embed all:i18n/locales
var localesRoot embed.FS
// stringList is a flag.Value that collects repeated string flags. The first
// time the user passes -log-file the seeded default ("console") is dropped;
// subsequent passes append. Lets the user replace or extend the log target
// list without a separate "reset" flag.
type stringList struct {
values []string
userSet bool
}
func (s *stringList) String() string {
return strings.Join(s.values, ",")
}
func (s *stringList) Set(v string) error {
if !s.userSet {
s.values = nil
s.userSet = true
}
s.values = append(s.values, v)
return nil
}
func init() {
application.RegisterEvent[services.Status](services.EventStatusSnapshot)
application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification)
application.RegisterEvent[services.ProfileRef](services.EventProfileChanged)
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
application.RegisterEvent[updater.State](updater.EventStateChanged)
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
}
func main() {
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port")
logFiles := &stringList{values: []string{"console"}}
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console.")
logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.")
flag.Parse()
if err := util.InitLog(*logLevel, logFiles.values...); err != nil {
log.Fatalf("init log: %v", err)
}
conn := NewConn(*daemonAddr)
// tray is captured in the SingleInstance callback below; the var is
// declared before app.New so the closure has a stable reference.
var tray *Tray
// On macOS, application.Options.Icon is fed into NSApplication's
// setApplicationIconImage at startup, which would override the bundle
// icon (Assets.car / icons.icns) the OS already picked. We want the
// bundle's squircle to stay, so suppress it on darwin.
appIcon := iconWindow
if runtime.GOOS == "darwin" {
appIcon = nil
}
app := application.New(application.Options{
// Windows uses Name as the AppUserModelID for toast notifications
// (see notifications_windows.go: cfg.Name -> wn.appName -> AppID)
// and as the registry path under HKCU\Software\Classes\AppUserModelId\.
// Must match the System.AppUserModel.ID value the MSI sets on the
// Start Menu shortcut (client/netbird.wxs) and the AppUserModelId
// key the installer pre-populates with the toast activator CLSID;
// otherwise toasts show under a different identity and the MSI's
// CustomActivator registry value is orphaned.
Name: "NetBird",
Description: "NetBird desktop client",
Icon: appIcon,
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
Linux: application.LinuxOptions{
ProgramName: "netbird",
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "io.netbird.ui",
OnSecondInstanceLaunch: func(_ application.SecondInstanceData) {
if tray != nil {
tray.ShowWindow()
}
},
},
})
settings := services.NewSettings(conn)
profiles := services.NewProfiles(conn)
// updater.Holder owns the typed update State. DaemonFeed pipes the
// daemon SubscribeEvents stream into it; the Update service is a thin
// Wails-bound facade over the holder plus the install RPCs.
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder)
notifier := notifications.New()
// localesFS reroots the embedded tree at the locales directory itself
// so the bundle sees _index.json and <lang>/common.json at the top
// level (the //go:embed path is rooted at the package, not the leaf
// dir).
localesFS, err := fs.Sub(localesRoot, "i18n/locales")
if err != nil {
log.Fatalf("locate locales fs: %v", err)
}
// Build the domain layer first, then wrap it in the Wails-bound
// services. The Bundle satisfies preferences.LanguageValidator so
// SetLanguage rejects codes that have no shipped translation.
bundle, err := i18n.NewBundle(localesFS)
if err != nil {
log.Fatalf("init i18n bundle: %v", err)
}
prefStore, err := preferences.NewStore(bundle, app.Event)
if err != nil {
log.Fatalf("init preferences store: %v", err)
}
localizer := NewLocalizer(bundle, prefStore)
// Connection lives after bundle + prefStore so it can localise daemon
// errors (services.NewConnection takes both as dependencies).
connection := services.NewConnection(conn, bundle, prefStore)
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
app.RegisterService(application.NewService(connection))
// authsession.Session owns the full extend + dismiss surface; the tray
// drives the "Extend now" action from the T-10 OS notification through
// this directly. The Wails-bound services.Session wraps only the subset
// the React frontend calls, so the generated TS surface stays minimal.
authSession := authsession.NewSession(conn)
app.RegisterService(application.NewService(services.NewSession(authSession)))
app.RegisterService(application.NewService(settings))
networks := services.NewNetworks(conn)
app.RegisterService(application.NewService(networks))
app.RegisterService(application.NewService(services.NewForwarding(conn)))
app.RegisterService(application.NewService(profiles))
app.RegisterService(application.NewService(services.NewDebug(conn)))
app.RegisterService(application.NewService(update))
app.RegisterService(application.NewService(daemonFeed))
app.RegisterService(application.NewService(notifier))
app.RegisterService(application.NewService(profileSwitcher))
app.RegisterService(application.NewService(services.NewI18n(bundle)))
app.RegisterService(application.NewService(services.NewPreferences(prefStore)))
// Open the main window at the width matching the user's last view
// choice so an Advanced-mode user doesn't see the window pop from 380px
// to 900px on every launch. Height is the same in both modes.
initialWidth := 380
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
initialWidth = 900
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "NetBird",
Width: initialWidth,
Height: 640,
Hidden: true,
BackgroundColour: application.NewRGB(24, 26, 29),
URL: "/",
DisableResize: true,
MaximiseButtonState: application.ButtonHidden,
Mac: application.MacWindow{
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropNormal,
TitleBar: application.MacTitleBarHiddenInset,
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
},
Windows: application.WindowsWindow{
Theme: application.Dark,
},
Linux: application.LinuxWindow{
Icon: iconWindow,
},
})
// Intercept the window close to hide instead of quit. The user reaches
// "really quit" via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
e.Cancel()
window.Hide()
})
// The settings and browser-login windows are created lazily and
// destroyed on close, so they don't linger as hidden windows that
// Wails's macOS dock-reopen handler would pop back up.
windowManager := services.NewWindowManager(app, window)
app.RegisterService(application.NewService(windowManager))
// Register an in-process StatusNotifierWatcher so the tray works on
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
// AppIndicator extension) that don't ship one themselves. No-op on
// non-Linux platforms. Must run before NewTray so the Wails systray's
// RegisterStatusNotifierItem call hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, window, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
Networks: networks,
DaemonFeed: daemonFeed,
Notifier: notifier,
Update: update,
ProfileSwitcher: profileSwitcher,
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
})
listenForShowSignal(context.Background(), tray)
daemonFeed.Watch(context.Background())
if err := app.Run(); err != nil {
log.Fatal(err)
}
}