Files
netbird/client/ui/tray_update.go
Zoltan Papp e2a4fdfe07 [client] Create GUI windows on demand and destroy them on close
The main and Settings windows were created at startup and kept alive hidden on
close, so an idle tray held two webview processes for surfaces the user may
never open. Both are now built on first show and destroyed on close, which
takes the idle footprint on macOS from ~160 MB to ~74 MB.

The WindowManager owns creation: it rebuilds the main window on the next show
and hands out live pointers, since a stored one goes stale. Every show is
deferred until the frontend reports it has rendered, so a freshly created
window is never on screen empty, with a timeout so a frontend that never
reports cannot strand a window hidden.
2026-08-07 14:42:18 +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)
}