Files
netbird/client/ui/main.go
T
Zoltan Papp 2d28f9002a [client] Fix the Windows tray deadlock on re-entrant window creation (#7449)
* [client] Fix the Windows tray deadlock on re-entrant window creation

The Wails systray runs the left-click handler synchronously inside the
tray window procedure, and creating a window on a running app pumps a
nested Win32 message loop while WebView2 initialises. ensureWindow held
the non-reentrant createMu across that creation, so the second button-up
of a double click re-entered ShowWindow from the pump and blocked the
main thread on its own lock. A goroutine holding createMu while the main
thread pumped, and the Open* dialogs holding mu across NewWithOptions,
Show, Hide and InvokeSync, exposed the same inversion.

WindowManager now serialises creation with a per-slot creating flag and
queues the callers' operations until the window exists, and no Wails call
runs while mu is held. The tray click and second-instance handlers call
ShowWindow off the message loop.

* [client] Serialize window operations while a slot is being created

Callers arriving after the window is published but before the creator
has drained the queue took the existing-window fast path and could run
ahead of older queued operations, so a newer SetURL could be overwritten
by an older one. withWindow now queues every caller while the creating
flag is set and clears the flag only once the queue is seen empty under
the lock.

A factory panic or a nil window left the creating flag set and the slot
dead; creation and drain now reset that state on early exit.

hideOtherWindows records the windows it hid only when no restore ran
in between, tracked by a generation counter, and re-shows them otherwise,
so a restore racing the hide cannot strand hidden windows.

* [misc] Run the client/ui subpackage tests in CI

The three test workflows filtered the package list with a `/client/ui`
prefix match, which dropped the subpackages along with the package that
cannot compile without a frontend build. `services`, `preferences`,
`i18n` and `authsession` all carry Go-side unit tests that never ran,
including the window manager re-entrancy regression test.

Anchor the pattern so only `client/ui` itself is excluded. The linux leg
keeps the prefix match on 386, where only the 64-bit gtk4/webkitgtk dev
packages are installed and the Wails application package would fail to
link, and the alpine container job keeps it for the same reason.

* [misc] Run the client/ui subpackage tests on a gtk4 4.10 runner

The previous commit let the subpackages into the linux client job, where
client/ui/services failed to build: the wails runtime's linux cgo layer
uses GtkFileDialog, which arrived in gtk4 4.10, and the job's ubuntu-22.04
runner ships 4.6.

Move them to their own job pinned to ubuntu-24.04 and restore the linux
client job's original exclusion, leaving the 386 and privileged legs on
the runner they have used since 2024. The new job needs no build cache,
sudo or privileged tag, so it stays a few seconds long.

Darwin and Windows keep the anchored pattern from the previous commit and
already run these tests green, including the window manager re-entrancy
regression test on the platform the deadlock was reported on.

* [client] Defer a window close that lands while the window is still being created

WindowManager publishes a dialog's slot only after the factory returns,
and on Windows the factory blocks in the WebView2 embed pump. A Close*
arriving in that gap found a nil slot and returned without doing
anything, so the dialog appeared afterwards for a flow that had already
been cancelled. The pre-fix Open* dialog functions held mu across the
whole creation, which blocked a concurrent Close* until the slot was
set; removing that lock hold reopened this gap.

Close* now goes through closeWindow: while the slot is being created it
records a closer in pendingClose, and finishCreation runs that closer
before any queued operation, so a window that is going away is never
shown and Wails never sees a Show on a destroyed window, which would
recreate it. Ops queued behind a close are dropped; windowOp carries no
factory, so they cannot be replayed into a new creation, and the
frontend callers reissue on the next state change.

The browser-login slot uses the same restoring closer from both
CloseBrowserLogin and CloseRenewFlow, since the popup's WindowClosing
hook only restores on a user close. Where two closers race one
creation the first registered wins, so a later caller cannot replace a
restoring closer with one that does not restore.
2026-09-14 15:37:46 +02:00

405 lines
16 KiB
Go

//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"embed"
"flag"
"io/fs"
"log"
"os"
"runtime"
"strings"
"github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"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
// localesRoot embeds the i18n bundles shared by the tray (Go) and the React
// UI (Vite imports the same files). The `all:` prefix is required so
// _index.json is included — //go:embed drops files starting with "_" or "."
// otherwise.
//
//go:embed all:i18n/locales
var localesRoot embed.FS
// stringList collects repeated string flags. The first user-supplied value
// drops the seeded default; subsequent passes append.
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
}
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 *Notifier
compat *services.Compat
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)
application.RegisterEvent[services.SystemTheme](services.EventSystemThemeChanged)
}
func main() {
// The one-shot that applies the settings the daemon restricts to
// root/administrator, which this binary runs itself as under the platform's
// elevation prompt. Handled before anything GUI so no window, tray or
// single-instance lock is involved.
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
os.Exit(runPrivilegedSettings(os.Args[1:]))
}
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
conn := NewConn(daemonAddr)
// Without --log-file, the GUI manages a gui-client.log that follows the
// daemon's debug level and is collected in the debug bundle. It rides
// DaemonFeed's SubscribeEvents stream (see guilog.DebugLog).
debugLog := newDebugLog(userSetLogFile)
// Declared before app.New so the SingleInstance callback closes over it.
var tray *Tray
app := newApplication(func() {
if tray != nil {
go tray.ShowWindow()
}
})
profiles := services.NewProfiles(conn)
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
// Update service is a thin Wails-bound facade over it plus the install RPCs.
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := newNotifier()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
// notification-center delegate. No-op on Linux/Windows (stubs report
// authorized).
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
go requestNotificationAuthorization(notifier)
initDockObserver()
})
bundle, prefStore, localizer := buildI18n(app)
// Before any window exists so creation-time backgrounds are already themed.
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
// After bundle + prefStore: both are used to localise daemon errors.
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
connection := services.NewConnection(conn, bundle, prefStore)
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
// authsession.Session owns the full extend + dismiss surface the tray
// drives directly; the Wails-bound services.Session wraps only the subset
// the React frontend calls, keeping the generated TS surface 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,
compat: compat,
profileSwitcher: profileSwitcher,
bundle: bundle,
prefStore: prefStore,
})
windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
windowManager.SetMainFactory(func(startURL string) *application.WebviewWindow {
return newMainWindow(app, prefStore, windowManager, startURL)
})
registerDockReopenHook(app, windowManager)
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
// position across hide -> show, dropping them top-left. Gate Go-side
// re-centering on that environment; nil leaves placement to the WM on full
// desktops, macOS, and Windows.
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
app.RegisterService(application.NewService(windowManager))
// Welcome window, first launch only — Continue flips OnboardingCompleted
// so later launches skip it. 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()
})
}
// In-process StatusNotifierWatcher so the tray works on minimal WMs that
// don't ship one (Fluxbox, i3, GNOME without AppIndicator). No-op off
// Linux. Must run before NewTray so the systray's
// RegisterStatusNotifierItem hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, nil, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
Networks: networks,
DaemonFeed: daemonFeed,
Notifier: notifier,
Update: update,
ProfileSwitcher: profileSwitcher,
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
Preferences: prefStore,
})
listenForShowSignal(context.Background(), tray)
// Start the feed only after every service's ServiceStartup has run. The
// first SubscribeEvents message replays cached state synchronously and can
// fire an OS notification; if Watch ran before app.Run it could beat the
// notifier's ServiceStartup, where the Linux notifier connects the session
// bus — its *dbus.Conn would still be nil and SendNotification would
// nil-deref (fatal panic on the dispatch goroutine, observed on Linux
// Mint). ApplicationStarted fires after the startup loop, so the bus is up.
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
daemonFeed.Watch(context.Background())
// Probe daemon compatibility once the notifier bus is up; an outdated
// daemon may keep the main window from showing, so the OS toast is the
// only reliable signal the user gets.
go notifyIfDaemonOutdated(compat, notifier, localizer)
// One-time launch-on-login default for fresh installs; gated by the
// NetBird footprint check, MDM policy, and the persisted marker.
go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad())
})
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
// requestNotificationAuthorization prompts for macOS notification permission.
// The request blocks until the user responds (up to 3 minutes), so callers run
// it in a goroutine. No-op on Linux/Windows.
func requestNotificationAuthorization(notifier *Notifier) {
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 returns the daemon gRPC address and userSetLogFile
// (true when --log-file was passed). userSetLogFile is the manual-override
// signal: true leaves logging alone, false lets the GUI manage a
// daemon-driven gui-client.log. The flag default is empty (not "console") so
// "no flag" and an explicit "--log-file console" stay distinguishable; empty
// 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 fires when
// a second process launches under the same SingleInstance UniqueID.
func newApplication(onSecondInstance func()) *application.App {
// On macOS, Options.Icon feeds NSApplication's setApplicationIconImage,
// overriding the bundle icon (Assets.car / icons.icns) the OS already
// picked. Suppress it on darwin to keep the bundle's squircle.
appIcon := iconWindow
if runtime.GOOS == "darwin" {
appIcon = nil
}
return application.New(application.Options{
// On Windows, Name is the AppUserModelID for toast notifications and
// the HKCU\Software\Classes\AppUserModelId\ registry path. It must
// match the System.AppUserModel.ID 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
// value is orphaned.
Name: "NetBird",
Description: "NetBird desktop client",
Icon: appIcon,
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
ActivationPolicy: application.ActivationPolicyAccessory,
},
Linux: application.LinuxOptions{
ProgramName: "netbird",
DisableQuitOnLastWindowClosed: true,
},
Windows: application.WindowsOptions{
WndProcInterceptor: endSessionInterceptor(),
DisableQuitOnLastWindowClosed: true,
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "io.netbird.ui",
OnSecondInstanceLaunch: func(_ application.SecondInstanceData) {
onSecondInstance()
},
},
})
}
// buildI18n constructs the i18n bundle, preferences store, and 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) {
// Reroot the embedded tree at the locales dir so the bundle sees
// _index.json and <lang>/common.json at top level (//go:embed roots 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 with no other caller are constructed inline; the rest arrive
// already built so the tray and feed loops 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, s.bundle, s.prefStore)))
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()))
app.RegisterService(application.NewService(s.compat))
}
func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager, startURL string) *application.WebviewWindow {
// Width matches the last view mode so Advanced-mode users don't see the
// window pop from 380px to 900px on launch. Height is mode-agnostic.
initialWidth := 380
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
initialWidth = 900
}
appearance := services.CurrentAppearance()
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "NetBird",
Width: initialWidth,
Height: services.WindowHeight,
// Center on first show; minimal WMs (fluxbox, the XEmbed tray path)
// drop new windows top-left unless asked.
InitialPosition: application.WindowCentered,
Hidden: true,
BackgroundColour: services.WindowBackgroundColour(appearance),
URL: startURL,
DisableResize: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
Mac: services.AppleMacOSAppearanceOptions(appearance),
Windows: services.MicrosoftWindowsAppearanceOptions(appearance),
Linux: application.LinuxWindow{
Icon: iconWindow,
},
})
window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
if services.ShuttingDown() {
return
}
wm.ForgetMain()
})
return window
}
func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
if runtime.GOOS != "darwin" {
return
}
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
if e.Context().HasVisibleWindows() {
return
}
e.Cancel()
wm.ShowMain()
})
}