diff --git a/client/ui/main.go b/client/ui/main.go index 9a3e17743..656acd61f 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -103,6 +103,18 @@ func main() { updaterHolder := updater.NewHolder(app.Event) update := services.NewUpdate(conn, updaterHolder) daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) + // Status snapshots go only to visible windows — a snapshot is full state, + // so a hidden webview loses nothing by being skipped; it gets the cached + // one on show (SetShowReplay below). Every other event stays on the bus. + daemonFeed.SetWindowDispatcher(func(st services.Status) { + ev := &application.CustomEvent{Name: services.EventStatusSnapshot, Data: st} + for _, w := range app.Window.GetAll() { + if w == nil || !w.IsVisible() { + continue + } + w.DispatchWailsEvent(ev) + } + }) notifier := notifications.New() compat := services.NewCompat(conn) // macOS shows no toast until permission is requested. Run it after @@ -152,8 +164,29 @@ func main() { // re-centering on that environment; nil leaves placement to the WM on full // desktops, macOS, and Windows. windowManager.SetRecenterOnShow(recenterOnShowPredicate()) + // Replay the latest snapshot into a window on (re)show, so a webview that + // was hidden while pushes flowed never paints stale state. + windowManager.SetShowReplay(func(w application.Window) { + if st := daemonFeed.LastStatus(); st != nil { + w.DispatchWailsEvent(&application.CustomEvent{Name: services.EventStatusSnapshot, Data: *st}) + } + }) app.RegisterService(application.NewService(windowManager)) + // On macOS, Wails' default applicationShouldHandleReopen handler Show()s + // every hidden window on dock-icon click, resurrecting hide-on-close + // surfaces like Settings. Cancel it in a hook (hooks run before listeners) + // and show only the main window. No-op elsewhere — 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 + } + windowManager.ShowMain() + }) + } + // 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. @@ -377,20 +410,5 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat window.Hide() }) - // On macOS, Wails' default applicationShouldHandleReopen handler Show()s - // every hidden window on dock-icon click, resurrecting hide-on-close - // surfaces like Settings. Cancel it in a hook (hooks run before listeners) - // and show only the main window. No-op elsewhere — 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 } diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 632581fe9..082edfc7b 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -5,6 +5,7 @@ package services import ( "context" "fmt" + "slices" "strings" "sync" "time" @@ -167,6 +168,14 @@ type DaemonFeed struct { cancel context.CancelFunc streamWg sync.WaitGroup + // statusSubs are Go-side snapshot consumers (the tray), fed directly so + // they don't ride the window event bus. Callbacks run synchronously on + // the stream goroutine, so pushes arrive in order. + statusSubsMu sync.Mutex + statusSubs []func(Status) + lastStatus *Status + windowDispatcher func(Status) + switchMu sync.Mutex switchInProgress bool switchInProgressUntil time.Time @@ -188,6 +197,50 @@ func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Hold return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl} } +// OnStatus registers a Go-side status subscriber. Not for the frontend — +// React consumers subscribe to EventStatusSnapshot on the event bus. +func (s *DaemonFeed) OnStatus(cb func(Status)) { + s.statusSubsMu.Lock() + s.statusSubs = append(s.statusSubs, cb) + s.statusSubsMu.Unlock() +} + +// SetWindowDispatcher installs the frontend push path: it receives every +// snapshot and decides which webview windows get it (visible ones). While +// unset, pushStatus falls back to the event-bus broadcast. +func (s *DaemonFeed) SetWindowDispatcher(fn func(Status)) { + s.statusSubsMu.Lock() + s.windowDispatcher = fn + s.statusSubsMu.Unlock() +} + +// pushStatus delivers a snapshot to the Go-side subscribers and the frontend, +// and caches it for LastStatus replays. Hidden windows are skipped by the +// window dispatcher; they catch up via the show replay (WindowManager). +func (s *DaemonFeed) pushStatus(st Status) { + s.statusSubsMu.Lock() + s.lastStatus = &st + subs := slices.Clone(s.statusSubs) + dispatch := s.windowDispatcher + s.statusSubsMu.Unlock() + for _, cb := range subs { + cb(st) + } + if dispatch != nil { + dispatch(st) + return + } + s.emitter.Emit(EventStatusSnapshot, st) +} + +// LastStatus returns the most recently pushed snapshot, or nil before the +// first push. Windows becoming visible replay it so they never paint stale. +func (s *DaemonFeed) LastStatus() *Status { + s.statusSubsMu.Lock() + defer s.statusSubsMu.Unlock() + return s.lastStatus +} + // BeginProfileSwitch arms suppression for a switch from Connected/Connecting, // where the daemon emits stale Connected updates during Down's teardown then an // Idle before the new Up; statusStreamLoop drops those, and a synthetic @@ -201,7 +254,7 @@ func (s *DaemonFeed) BeginProfileSwitch() { s.switchLoginWatch = true s.switchLoginWatchUntil = now.Add(30 * time.Second) s.switchMu.Unlock() - s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting}) + s.pushStatus(Status{Status: StatusConnecting}) } // CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting): @@ -343,7 +396,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { return } unavailable = true - s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable}) + s.pushStatus(Status{Status: StatusDaemonUnavailable}) } op := func() error { @@ -403,7 +456,7 @@ func (s *DaemonFeed) emitStatus(st Status) { log.Debugf("suppressing status=%q during profile switch", st.Status) return } - s.emitter.Emit(EventStatusSnapshot, st) + s.pushStatus(st) if triggerLogin { s.emitter.Emit(EventTriggerLogin) } diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index bac9790b4..227e161e4 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -115,6 +115,9 @@ type WindowManager struct { // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor // restores position; nil on full desktops so re-centering can't fight a user-moved window. recenterOnShow func() bool + // showReplay fires whenever a live-but-hidden window is (re)shown, so the + // caller can replay the latest status snapshot into its webview. + showReplay func(application.Window) } // NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The @@ -174,6 +177,7 @@ func (s *WindowManager) OpenSettings(tab string) { s.app.Event.Emit(EventSettingsOpen, target) s.settings.Show() s.settings.Focus() + s.notifyShown(s.settings) // Re-center (minimal-WM only; see centerWhenReady). s.centerWhenReady(s.settings) } @@ -220,6 +224,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { s.centerOnCursorScreen(s.browserLogin) s.browserLogin.Show() s.browserLogin.Focus() + s.notifyShown(s.browserLogin) } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -280,6 +285,7 @@ func (s *WindowManager) OpenSessionExpiration(seconds int) { s.centerOnCursorScreen(s.sessionExpiration) s.sessionExpiration.Show() s.sessionExpiration.Focus() + s.notifyShown(s.sessionExpiration) } func (s *WindowManager) CloseSessionExpiration() { @@ -347,6 +353,7 @@ func (s *WindowManager) OpenInstallProgress(version string) { s.installProgress.SetURL(startURL) s.installProgress.Show() s.installProgress.Focus() + s.notifyShown(s.installProgress) s.centerWhenReady(s.installProgress) } @@ -380,6 +387,7 @@ func (s *WindowManager) OpenWelcome() { } s.welcome.Show() s.welcome.Focus() + s.notifyShown(s.welcome) s.centerWhenReady(s.welcome) } @@ -417,6 +425,7 @@ func (s *WindowManager) OpenError(title, message string) { s.errorDialog.SetURL(startURL) s.errorDialog.Show() s.errorDialog.Focus() + s.notifyShown(s.errorDialog) s.centerWhenReady(s.errorDialog) } @@ -443,6 +452,7 @@ func (s *WindowManager) ShowMain() { } s.mainWindow.Show() s.mainWindow.Focus() + s.notifyShown(s.mainWindow) // Re-center (minimal-WM only; see centerWhenReady). s.centerWhenReady(s.mainWindow) } @@ -452,6 +462,17 @@ func (s *WindowManager) SetRecenterOnShow(pred func() bool) { s.recenterOnShow = pred } +// SetShowReplay installs the shown-window hook (see the showReplay field). +func (s *WindowManager) SetShowReplay(fn func(application.Window)) { + s.showReplay = fn +} + +func (s *WindowManager) notifyShown(w application.Window) { + if s.showReplay != nil && w != nil { + s.showReplay(w) + } +} + // centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it // returns so it never fights a user-moved window. On GTK4 an inline Center() // no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync @@ -572,6 +593,7 @@ func (s *WindowManager) restoreHiddenWindowsLocked() { continue } w.Show() + s.notifyShown(w) if w == s.mainWindow { mainRestored = true } diff --git a/client/ui/tray.go b/client/ui/tray.go index 7a1f74770..5f1e854f1 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -171,7 +171,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe // in the right locale — no English flash then re-paint. loc: svc.Localizer, } - t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) + t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }, func() { t.showMainWindow() }) t.tray = app.SystemTray.New() // Seed panel-theme detection before the first paint so the initial icon // matches the panel's light/dark scheme (Linux only). @@ -195,7 +195,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe // menu (e.g. GNOME Shell AppIndicator). bindTrayClick(t) - app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) + svc.DaemonFeed.OnStatus(t.applyStatus) app.Event.On(services.EventDaemonNotification, t.onSystemEvent) // Refresh the Profiles submenu on ProfileSwitcher's change event. A // switch on an idle daemon drives no status transition, so without this @@ -252,6 +252,19 @@ func (t *Tray) ShowWindow() { t.window.Focus() } +// showMainWindow brings the main window forward through the WindowManager so +// the status replay and re-centering apply; falls back to a bare Show in tests. +func (t *Tray) showMainWindow() { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + if t.window != nil { + t.window.Show() + t.window.Focus() + } +} + // applyLanguage re-renders every translated surface in the Localizer's current // language. Wails dispatches menu/tray APIs onto the UI thread internally, so // calling them from the Localizer's background goroutine is safe; profileLoadMu diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 45caf2a37..ebd78c934 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -30,11 +30,11 @@ const ( // handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal. func (t *Tray) handleSessionExpired() { t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) - if t.window != nil { - t.window.SetURL("/#/login") - t.window.Show() - t.window.Focus() + if t.window == nil { + return } + t.window.SetURL("/#/login") + t.showMainWindow() } // applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. @@ -308,8 +308,7 @@ func (t *Tray) openSessionExtendFlow() { if seconds <= 0 { if t.window != nil { t.window.SetURL("/#/login") - t.window.Show() - t.window.Focus() + t.showMainWindow() } return } diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go index 793f1785a..1de595a12 100644 --- a/client/ui/tray_status.go +++ b/client/ui/tray_status.go @@ -5,19 +5,9 @@ package main import ( "strings" - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/netbirdio/netbird/client/ui/services" ) -func (t *Tray) onStatusEvent(ev *application.CustomEvent) { - st, ok := ev.Data.(services.Status) - if !ok { - return - } - t.applyStatus(st) -} - // applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped // when no icon-relevant input changed: the daemon emits rapid SubscribeStatus // bursts during health probes that would otherwise spam Shell_NotifyIcon. diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index 1a377dfa3..779fe6517 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -28,6 +28,9 @@ type trayUpdater struct { // 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() + // showMain brings the main window forward via the WindowManager (status + // replay + centering); nil falls back to a bare window.Show. + showMain func() mu sync.Mutex item *application.MenuItem @@ -36,7 +39,7 @@ type trayUpdater struct { progressWindowOpen bool } -func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { +func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange, onMenuChange, showMain func()) *trayUpdater { u := &trayUpdater{ app: app, window: window, @@ -45,6 +48,7 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd loc: loc, onIconChange: onIconChange, onMenuChange: onMenuChange, + showMain: showMain, } app.Event.On(updater.EventStateChanged, u.onStateEvent) // Seed from cached state to cover an event that fired before wiring completed. @@ -193,6 +197,10 @@ func (u *trayUpdater) openProgressWindow(version string) { url += "?version=" + version } u.window.SetURL(url) + if u.showMain != nil { + u.showMain() + return + } u.window.Show() u.window.Focus() }