mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
When the daemon is set to debug/trace, the GUI now automatically writes a rotated gui-client.log in the user's config dir and the daemon's debug bundle collects it. The UI learns the level both at startup (daemon already in debug) and live, by piggybacking the existing SubscribeEvents stream: the daemon publishes a marked log-level-changed SystemEvent (and a per-subscription snapshot), which DaemonFeed routes to guilog.DebugLog instead of an OS toast. The UI registers its log path via a new RegisterUILog RPC so the root daemon, which can't resolve the user's config dir, knows where to find the file. Manual --log-file (any value) disables the daemon-driven file logging. Fix: client/ui SetLogLevel looked up proto.LogLevel_value with the lowercase logrus name, which never matched the uppercase enum keys and silently fell back to INFO — so trace/debug requests from the bundle flow had no effect.
412 lines
17 KiB
Go
412 lines
17 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"flag"
|
|
"io/fs"
|
|
"log"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"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/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
|
|
}
|
|
|
|
// registeredServices bundles the constructed services that registerServices
|
|
// binds to the Wails app, keeping the call site readable.
|
|
type registeredServices struct {
|
|
connection *services.Connection
|
|
authSession *authsession.Session
|
|
settings *services.Settings
|
|
networks *services.Networks
|
|
profiles *services.Profiles
|
|
update *services.Update
|
|
daemonFeed *services.DaemonFeed
|
|
notifier *notifications.NotificationService
|
|
profileSwitcher *services.ProfileSwitcher
|
|
bundle *i18n.Bundle
|
|
prefStore *preferences.Store
|
|
}
|
|
|
|
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, userSetLogFile := parseFlagsAndInitLog()
|
|
conn := NewConn(daemonAddr)
|
|
|
|
// GUI file logging: when the user didn't pass --log-file, the GUI manages a
|
|
// gui-client.log that follows the daemon's debug level (attached when the
|
|
// daemon is in debug/trace, detached otherwise, rotated by timberjack) and is
|
|
// included in the debug bundle. It rides DaemonFeed's SubscribeEvents stream
|
|
// (passed into NewDaemonFeed below; see guilog.DebugLog).
|
|
debugLog := newDebugLog(userSetLogFile)
|
|
|
|
// 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
|
|
app := newApplication(func() {
|
|
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, debugLog)
|
|
notifier := notifications.New()
|
|
// macOS won't surface any toast until the app has requested permission;
|
|
// the request runs after ApplicationStarted so the notifier's Startup has
|
|
// initialised the notification-center delegate. Linux/Windows stubs return
|
|
// authorized, so this is a no-op there.
|
|
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
|
go requestNotificationAuthorization(notifier)
|
|
})
|
|
|
|
bundle, prefStore, localizer := buildI18n(app)
|
|
|
|
// 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)
|
|
// 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)
|
|
networks := services.NewNetworks(conn)
|
|
|
|
registerServices(app, conn, registeredServices{
|
|
connection: connection,
|
|
authSession: authSession,
|
|
settings: settings,
|
|
networks: networks,
|
|
profiles: profiles,
|
|
update: update,
|
|
daemonFeed: daemonFeed,
|
|
notifier: notifier,
|
|
profileSwitcher: profileSwitcher,
|
|
bundle: bundle,
|
|
prefStore: prefStore,
|
|
})
|
|
|
|
window := newMainWindow(app, prefStore)
|
|
|
|
// Settings is created eagerly (hidden) inside NewWindowManager so the
|
|
// first click on the gear paints instantly and the React side keeps
|
|
// per-tab state across reopens. The other auxiliary windows
|
|
// (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close
|
|
// so they don't linger as hidden windows that Wails's macOS dock-reopen
|
|
// handler would pop back up.
|
|
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
|
|
// On minimal WMs (the in-process XEmbed-tray path) the WM neither centers
|
|
// small windows nor restores their position across a hide -> show, so the
|
|
// main/Settings windows would open in the top-left corner. Gate Go-side
|
|
// re-centering on that environment; nil (full desktops, macOS, Windows)
|
|
// leaves placement to the WM. See WindowManager.SetRecenterOnShow.
|
|
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
|
|
app.RegisterService(application.NewService(windowManager))
|
|
|
|
// Welcome / onboarding window. First launch only — the Continue
|
|
// button in the dialog flips OnboardingCompleted=true via the
|
|
// Preferences service before closing, so subsequent launches skip
|
|
// straight to the tray-only flow. ApplicationStarted hook so the
|
|
// Wails window machinery is fully up before the window is created.
|
|
if !prefStore.Get().OnboardingCompleted {
|
|
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
|
windowManager.OpenWelcome()
|
|
})
|
|
}
|
|
|
|
// Register an in-process StatusNotifierWatcher so the tray works on
|
|
// minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the
|
|
// AppIndicator extension) that don't ship one themselves. No-op on
|
|
// 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)
|
|
|
|
// Start the daemon event feed only after Wails has run every service's
|
|
// ServiceStartup. The very first daemon SubscribeEvents message replays
|
|
// the cached state (status + available update) synchronously, which fans
|
|
// out through app.Event into the tray's update-state listener and fires an
|
|
// OS notification. If Watch ran before app.Run, that send could beat the
|
|
// notifications service's ServiceStartup — on Linux the Wails notifier
|
|
// connects to the session bus there, so its *dbus.Conn would still be nil
|
|
// and SendNotification would nil-deref (fatal panic on the event-dispatch
|
|
// goroutine; observed on Linux Mint). ApplicationStarted fires inside
|
|
// app.Run after the synchronous service-startup loop, so the bus is up by
|
|
// the time the first event lands.
|
|
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
|
daemonFeed.Watch(context.Background())
|
|
})
|
|
|
|
if err := app.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// requestNotificationAuthorization prompts for macOS notification permission
|
|
// when the app first runs unauthorized. RequestNotificationAuthorization
|
|
// blocks until the user responds (up to 3 minutes on macOS), so callers run
|
|
// it in a goroutine. On Linux/Windows the Wails notifier stubs report
|
|
// authorized, making this a no-op.
|
|
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
|
authorized, err := notifier.CheckNotificationAuthorization()
|
|
if err != nil {
|
|
logrus.Debugf("check notification authorization: %v", err)
|
|
return
|
|
}
|
|
if authorized {
|
|
return
|
|
}
|
|
if _, err := notifier.RequestNotificationAuthorization(); err != nil {
|
|
logrus.Debugf("request notification authorization: %v", err)
|
|
}
|
|
}
|
|
|
|
// parseFlagsAndInitLog parses the CLI flags, initialises the logger, and
|
|
// returns the resolved daemon gRPC address plus userSetLogFile — true when the
|
|
// user passed --log-file explicitly. userSetLogFile is the manual-override
|
|
// signal: when true the GUI leaves logging alone (the daemon's debug level
|
|
// won't attach gui-client.log); when false the GUI manages a per-session
|
|
// gui-client.log driven by the daemon level. The default seed is empty (not
|
|
// "console") so "no flag" and an explicit "--log-file console" are
|
|
// distinguishable; an empty result falls back to console for InitLog.
|
|
func parseFlagsAndInitLog() (string, bool) {
|
|
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port")
|
|
logFiles := &stringList{}
|
|
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. Passing any value disables the daemon-debug-driven gui-client.log.")
|
|
logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.")
|
|
flag.Parse()
|
|
|
|
userSetLogFile := len(logFiles.values) > 0
|
|
targets := logFiles.values
|
|
if !userSetLogFile {
|
|
targets = []string{"console"}
|
|
}
|
|
|
|
if err := util.InitLog(*logLevel, targets...); err != nil {
|
|
log.Fatalf("init log: %v", err)
|
|
}
|
|
return *daemonAddr, userSetLogFile
|
|
}
|
|
|
|
// newApplication constructs the Wails application. onSecondInstance is invoked
|
|
// when a second process launches under the same SingleInstance UniqueID.
|
|
func newApplication(onSecondInstance func()) *application.App {
|
|
// 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
|
|
}
|
|
|
|
return 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) {
|
|
onSecondInstance()
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// buildI18n constructs the domain-layer i18n bundle, the preferences store,
|
|
// and the tray localizer. The Bundle satisfies preferences.LanguageValidator
|
|
// so SetLanguage rejects codes that have no shipped translation.
|
|
func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) {
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|
|
return bundle, prefStore, NewLocalizer(bundle, prefStore)
|
|
}
|
|
|
|
// registerServices binds every Wails-facing service onto the application.
|
|
// Services constructed inline here (Session, Forwarding, Debug, I18n,
|
|
// Preferences) have no other caller; the rest arrive already built so the
|
|
// tray and feed loops can share the same instances.
|
|
func registerServices(app *application.App, conn *Conn, s registeredServices) {
|
|
app.RegisterService(application.NewService(s.connection))
|
|
app.RegisterService(application.NewService(services.NewSession(s.authSession)))
|
|
app.RegisterService(application.NewService(s.settings))
|
|
app.RegisterService(application.NewService(s.networks))
|
|
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
|
app.RegisterService(application.NewService(s.profiles))
|
|
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
|
app.RegisterService(application.NewService(s.update))
|
|
app.RegisterService(application.NewService(s.daemonFeed))
|
|
app.RegisterService(application.NewService(s.notifier))
|
|
app.RegisterService(application.NewService(s.profileSwitcher))
|
|
app.RegisterService(application.NewService(services.NewI18n(s.bundle)))
|
|
app.RegisterService(application.NewService(services.NewPreferences(s.prefStore)))
|
|
app.RegisterService(application.NewService(services.NewAutostart(app.Autostart)))
|
|
app.RegisterService(application.NewService(services.NewVersion()))
|
|
app.RegisterService(application.NewService(services.NewUILog()))
|
|
}
|
|
|
|
// newMainWindow creates the hidden main window, sized to the user's last view
|
|
// mode, and installs the hide-on-close and macOS dock-reopen hooks.
|
|
func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
|
|
// 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: services.WindowHeight,
|
|
// Center on first show. Full DEs (GNOME/KDE) place small windows
|
|
// centered by default, but minimal WMs (fluxbox et al, the XEmbed
|
|
// tray path) drop new windows in the top-left corner unless asked.
|
|
InitialPosition: application.WindowCentered,
|
|
Hidden: true,
|
|
BackgroundColour: services.WindowBackgroundColour,
|
|
URL: "/",
|
|
DisableResize: true,
|
|
MinimiseButtonState: application.ButtonHidden,
|
|
MaximiseButtonState: application.ButtonHidden,
|
|
Mac: services.AppleMacOSAppearanceOptions(),
|
|
Windows: services.MicrosoftWindowsAppearanceOptions(),
|
|
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()
|
|
})
|
|
|
|
// On macOS, replace Wails' default applicationShouldHandleReopen handler
|
|
// (events_common_darwin.go setupCommonEvents) which calls Show() on
|
|
// every hidden window when the dock icon is clicked. That resurrects
|
|
// hide-on-close auxiliary surfaces like Settings. Cancel the event in
|
|
// a hook (hooks run synchronously, before listeners) and bring up only
|
|
// the main window. No-op on other platforms — the event never fires.
|
|
if runtime.GOOS == "darwin" {
|
|
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
|
|
e.Cancel()
|
|
if e.Context().HasVisibleWindows() {
|
|
return
|
|
}
|
|
window.Show()
|
|
window.Focus()
|
|
})
|
|
}
|
|
|
|
return window
|
|
}
|