Files
netbird/client/ui/tray_update.go
Zoltan Papp bc73e6bd71 [client] Debug: create GUI windows on demand and destroy them on close
Experiment on top of the memory profiler: the main and Settings windows are no
longer created at startup or kept alive hidden, so an idle tray holds no webview
process. The WindowManager owns creation, rebuilds the main window on the next
show, and defers every show until the frontend reports it has rendered, which
keeps an empty webview off screen.

Measured on macOS: idle footprint drops from ~160 MB to ~74 MB, at the cost of
~150-250 ms per window open.
2026-08-07 14:43:52 +02:00

197 lines
5.1 KiB
Go

//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/client/ui/updater"
"github.com/netbirdio/netbird/version"
)
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
type trayUpdater struct {
app *application.App
showMainAt func(url string)
update *services.Update
notifier *Notifier
loc *Localizer
onIconChange func()
// onMenuChange drives a full tray relayout: the update row lives in the
// About submenu, which KDE/Plasma caches on first open and never re-fetches
// on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints.
onMenuChange func()
mu sync.Mutex
item *application.MenuItem
state updater.State
notifiedVersion string
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
showMainAt: showMainAt,
update: update,
notifier: notifier,
loc: loc,
onIconChange: onIconChange,
onMenuChange: onMenuChange,
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from cached state to cover an event that fired before wiring completed.
u.state = update.GetState()
return u
}
// attach (re)binds the menu item on each Tray.buildMenu run. The caller owns the
// item's OnClick handler.
func (u *trayUpdater) attach(item *application.MenuItem) {
u.mu.Lock()
u.item = item
state := u.state
u.mu.Unlock()
u.refreshMenuItem(state)
}
// hasUpdate reports whether the tray should paint the "update available" icon.
func (u *trayUpdater) hasUpdate() bool {
u.mu.Lock()
defer u.mu.Unlock()
return u.state.Available
}
// applyLanguage re-renders the menu item label after a locale switch.
func (u *trayUpdater) applyLanguage() {
u.mu.Lock()
state := u.state
u.mu.Unlock()
u.refreshMenuItem(state)
}
// handleClick opens the installer download link when not Enforced, otherwise
// shows the progress page and asks the daemon to start the installer.
func (u *trayUpdater) handleClick() {
u.mu.Lock()
state := u.state
u.mu.Unlock()
if !state.Enforced {
_ = u.app.Browser.OpenURL(version.DownloadUrl())
return
}
u.openProgressWindow(state.Version)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := u.update.Trigger(ctx); err != nil {
log.Errorf("trigger update: %v", err)
}
}()
}
func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(updater.State)
if !ok {
log.Warnf("update state event payload not UpdateState: %T", ev.Data)
return
}
u.applyState(st)
}
// applyState diffs st against the cached state and drives the resulting side
// effects: icon repaint, menu refresh, new-version notification, progress window.
func (u *trayUpdater) applyState(st updater.State) {
u.mu.Lock()
prev := u.state
u.state = st
sendNotify := st.Available && st.Version != "" && st.Version != u.notifiedVersion
if sendNotify {
u.notifiedVersion = st.Version
}
showWindow := st.Installing && !u.progressWindowOpen
if showWindow {
u.progressWindowOpen = true
} else if !st.Installing {
u.progressWindowOpen = false
}
u.mu.Unlock()
// Full relayout rather than in-place: KDE layout-caches the About submenu, so
// a direct SetLabel/SetHidden wouldn't paint. Fall back if no hook was wired.
if u.onMenuChange != nil {
u.onMenuChange()
} else {
u.refreshMenuItem(st)
}
if prev.Available != st.Available && u.onIconChange != nil {
u.onIconChange()
}
if sendNotify {
u.sendUpdateNotification(st)
}
if showWindow {
u.openProgressWindow(st.Version)
}
}
func (u *trayUpdater) refreshMenuItem(st updater.State) {
u.mu.Lock()
item := u.item
u.mu.Unlock()
if item == nil {
return
}
if !st.Available {
item.SetHidden(true)
return
}
if st.Enforced {
item.SetLabel(u.loc.T("tray.menu.installVersion", "version", st.Version))
} else {
item.SetLabel(u.loc.T("tray.menu.downloadLatest"))
}
item.SetHidden(false)
}
func (u *trayUpdater) sendUpdateNotification(st updater.State) {
if u.notifier == nil {
return
}
body := u.loc.T("notify.update.body", "version", st.Version)
if st.Enforced {
body += u.loc.T("notify.update.enforcedSuffix")
}
_ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + st.Version,
Title: u.loc.T("notify.update.title"),
Body: body,
})
}
// openProgressWindow points the main window at the /update progress page and
// brings it forward.
func (u *trayUpdater) openProgressWindow(version string) {
if u.showMainAt == nil {
return
}
url := "/#/update"
if version != "" {
url += "?version=" + version
}
u.showMainAt(url)
}