mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-29 11:51:27 +02:00
Compare commits
12 Commits
main
...
test/gui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c81eab8472 | ||
|
|
c49333e48a | ||
|
|
8b4061cfaf | ||
|
|
175ca4f0e0 | ||
|
|
859ac60c90 | ||
|
|
28ff2b7b3b | ||
|
|
22e0b66412 | ||
|
|
b6deed39fb | ||
|
|
4c95b20251 | ||
|
|
4b915e4a5a | ||
|
|
e1d82fc326 | ||
|
|
29b97340d6 |
@@ -1,20 +1,27 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const statusCoalesceWindow = 200 * time.Millisecond
|
||||
|
||||
// SubscribeStatus pushes a fresh StatusResponse on every connection state
|
||||
// change. The first message is the current snapshot, so a re-subscribing
|
||||
// client doesn't need to also call Status. Subsequent messages fire when
|
||||
// the peer recorder reports any of: connected/disconnected/connecting,
|
||||
// management or signal flip, address change, or peers list change.
|
||||
//
|
||||
// The change channel coalesces bursts to a single tick. If the consumer
|
||||
// is slow the daemon drops extras (not blocks), and the next snapshot
|
||||
// the consumer pulls already reflects everything.
|
||||
// Bursts are coalesced deterministically: the first tick after a quiet
|
||||
// period is sent immediately, then a short window swallows the rest of the
|
||||
// burst and a single trailing snapshot covers whatever arrived meanwhile.
|
||||
// Every send is a full snapshot of the recorder's current state, so
|
||||
// swallowed ticks lose no information.
|
||||
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||
subID, ch := s.statusRecorder.SubscribeToStateChanges()
|
||||
defer func() {
|
||||
@@ -37,12 +44,50 @@ func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonSe
|
||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||
return err
|
||||
}
|
||||
pending, open := collectStatusBurst(stream.Context(), ch)
|
||||
if pending {
|
||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !open {
|
||||
return nil
|
||||
}
|
||||
case <-stream.Context().Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectStatusBurst waits out the coalesce window, absorbing further ticks.
|
||||
// pending reports whether any tick arrived; open is false when the channel
|
||||
// closed or the stream context ended.
|
||||
func collectStatusBurst(ctx context.Context, ch <-chan struct{}) (pending, open bool) {
|
||||
timer := time.NewTimer(statusCoalesceWindow)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return pending, false
|
||||
}
|
||||
pending = true
|
||||
case <-timer.C:
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return pending, false
|
||||
}
|
||||
pending = true
|
||||
default:
|
||||
}
|
||||
return pending, true
|
||||
case <-ctx.Done():
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||
resp, err := s.buildStatusResponse(stream.Context(), req)
|
||||
if err != nil {
|
||||
|
||||
@@ -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,31 @@ 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. ReplayLast keeps
|
||||
// the feed's status lock across the dispatch, so a racing live push can't
|
||||
// slip in between and then be overwritten by this older cached snapshot.
|
||||
windowManager.SetShowReplay(func(w application.Window) {
|
||||
daemonFeed.ReplayLast(func(st services.Status) {
|
||||
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 +412,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
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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 ReplayLast. 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)
|
||||
}
|
||||
|
||||
// ReplayLast feeds the most recently pushed snapshot to dispatch; no-op before
|
||||
// the first push. The status lock is held across the dispatch so a concurrent
|
||||
// pushStatus cannot deliver a newer snapshot in between the read and the
|
||||
// dispatch — the replayed value is never older than anything already delivered.
|
||||
func (s *DaemonFeed) ReplayLast(dispatch func(Status)) {
|
||||
s.statusSubsMu.Lock()
|
||||
defer s.statusSubsMu.Unlock()
|
||||
if s.lastStatus == nil {
|
||||
return
|
||||
}
|
||||
dispatch(*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 +259,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 +401,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 +461,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -67,9 +67,8 @@ type Tray struct {
|
||||
loc *Localizer
|
||||
|
||||
// menu and the *Item/*Submenu fields below are reassigned by buildMenu
|
||||
// on every relayout — touch them only with menuMu held. Exceptions:
|
||||
// the Connect/Disconnect OnClick closures capture their own item, and
|
||||
// refreshSessionExpiresLabel snapshots its item under menuMu.
|
||||
// on every relayout, which destroys the replaced tree — locate items and
|
||||
// call them with menuMu held, so a relayout can't destroy one mid-call.
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
// sessionExpiresItem shows the SSO deadline as a remaining-time label,
|
||||
@@ -172,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).
|
||||
@@ -196,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
|
||||
@@ -253,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
|
||||
@@ -286,6 +298,7 @@ func (t *Tray) relayoutMenu() {
|
||||
t.menuMu.Lock()
|
||||
defer t.menuMu.Unlock()
|
||||
|
||||
old := t.menu
|
||||
t.menu = t.buildMenu()
|
||||
|
||||
t.statusMu.Lock()
|
||||
@@ -356,6 +369,10 @@ func (t *Tray) relayoutMenu() {
|
||||
// Single push of the whole tree: on Linux one LayoutUpdated with fresh
|
||||
// container ids; on darwin an NSMenu rebuild against the cached pointer.
|
||||
t.tray.SetMenu(t.menu)
|
||||
|
||||
if old != nil {
|
||||
old.Destroy()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) buildMenu() *application.Menu {
|
||||
@@ -371,13 +388,11 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
|
||||
menu.AddSeparator()
|
||||
|
||||
// The OnClick closures capture the local item because t.upItem/t.downItem
|
||||
// are menuMu-guarded and must not be read from the click goroutine.
|
||||
upItem := menu.Add(t.loc.T("tray.menu.connect"))
|
||||
upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) })
|
||||
upItem.OnClick(func(*application.Context) { t.handleConnect() })
|
||||
t.upItem = upItem
|
||||
downItem := menu.Add(t.loc.T("tray.menu.disconnect"))
|
||||
downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) })
|
||||
downItem.OnClick(func(*application.Context) { t.handleDisconnect() })
|
||||
downItem.SetHidden(true)
|
||||
t.downItem = downItem
|
||||
|
||||
@@ -469,9 +484,7 @@ func (t *Tray) handleQuit() {
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
// handleConnect receives the clicked item from the buildMenu closure —
|
||||
// t.upItem is menuMu-guarded and must not be read here.
|
||||
func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
func (t *Tray) handleConnect() {
|
||||
// NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they
|
||||
// need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so
|
||||
// the React startLogin() (which owns the BrowserLogin popup) drives it;
|
||||
@@ -485,7 +498,7 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
return
|
||||
}
|
||||
upItem.SetEnabled(false)
|
||||
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, false)
|
||||
// Arm the SSO auto-handoff: Up() is async and the daemon may flip to
|
||||
// NeedsLogin on an SSO peer with no cached token. applyStatus consumes the
|
||||
// flag on that transition to trigger browser-login without a second Connect
|
||||
@@ -500,18 +513,25 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
|
||||
t.statusMu.Lock()
|
||||
t.pendingConnectLogin = false
|
||||
t.statusMu.Unlock()
|
||||
upItem.SetEnabled(true)
|
||||
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, true)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Tray) setItemEnabled(get func() *application.MenuItem, enabled bool) {
|
||||
t.menuMu.Lock()
|
||||
defer t.menuMu.Unlock()
|
||||
if item := get(); item != nil {
|
||||
item.SetEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDisconnect aborts any in-flight profile switch before sending Down —
|
||||
// otherwise the switcher's queued Up would reconnect right after, making the
|
||||
// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's
|
||||
// Idle push paints through instead of being swallowed by the suppression filter.
|
||||
// Receives the clicked item from the buildMenu closure (see handleConnect).
|
||||
func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
|
||||
downItem.SetEnabled(false)
|
||||
func (t *Tray) handleDisconnect() {
|
||||
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, false)
|
||||
t.profileMu.Lock()
|
||||
if t.switchCancel != nil {
|
||||
t.switchCancel()
|
||||
@@ -523,7 +543,7 @@ func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
|
||||
if err := t.svc.Connection.Down(context.Background()); err != nil {
|
||||
log.Errorf("disconnect: %v", err)
|
||||
t.notifyError(t.loc.T("notify.error.disconnect"))
|
||||
downItem.SetEnabled(true)
|
||||
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, true)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -59,10 +60,11 @@ func (t *Tray) loadConfig() {
|
||||
t.profileMu.Unlock()
|
||||
}
|
||||
|
||||
// loadProfiles fetches the profile list and relayouts the menu. Also called
|
||||
// from applyStatus to catch flips from another channel (CLI, autoconnect),
|
||||
// since the daemon emits no active-profile event. Full relayout (not
|
||||
// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment.
|
||||
// loadProfiles fetches the profile list and relayouts the menu when the rows
|
||||
// changed. Also called from applyStatus to catch flips from another channel
|
||||
// (CLI, autoconnect), since the daemon emits no active-profile event. Full
|
||||
// relayout (not Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's
|
||||
// doc comment.
|
||||
func (t *Tray) loadProfiles() {
|
||||
t.profileLoadMu.Lock()
|
||||
defer t.profileLoadMu.Unlock()
|
||||
@@ -80,11 +82,14 @@ func (t *Tray) loadProfiles() {
|
||||
}
|
||||
|
||||
t.profilesMu.Lock()
|
||||
changed := username != t.profilesUser || !slices.Equal(profiles, t.profiles)
|
||||
t.profiles = profiles
|
||||
t.profilesUser = username
|
||||
t.profilesMu.Unlock()
|
||||
|
||||
t.relayoutMenu()
|
||||
if changed {
|
||||
t.relayoutMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// fillProfileSubmenu paints cached profile rows into the freshly built submenu.
|
||||
|
||||
@@ -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.
|
||||
@@ -104,21 +104,19 @@ func sessionRefreshInterval(remaining time.Duration) time.Duration {
|
||||
}
|
||||
|
||||
// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu.
|
||||
// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout.
|
||||
// menuMu is held across the SetLabel so a relayout can't destroy the item mid-call.
|
||||
func (t *Tray) refreshSessionExpiresLabel() {
|
||||
t.menuMu.Lock()
|
||||
item := t.sessionExpiresItem
|
||||
t.menuMu.Unlock()
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
t.sessionMu.Lock()
|
||||
deadline := t.sessionExpiresAt
|
||||
t.sessionMu.Unlock()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
item.SetLabel(t.sessionRowLabel(deadline))
|
||||
t.menuMu.Lock()
|
||||
defer t.menuMu.Unlock()
|
||||
if t.sessionExpiresItem != nil {
|
||||
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(deadline))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) sessionRowLabel(deadline time.Time) string {
|
||||
@@ -310,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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user