diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index b8586214e..a1b8bcb0d 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -141,6 +141,8 @@ User-actionable operation failures (config save, profile switch, debug bundle, u The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-`, `netbird-event-`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`. +**All sends go through `safeSendNotification` (`tray_notify.go`)** — never call `Notifier.SendNotification`/`SendNotificationWithActions` directly. It swallows both errors *and panics*. The panic guard is load-bearing on Linux: Wails' notifier connects to the session bus in its `ServiceStartup`, and when that connect fails (headless box, no/unreachable `DBUS_SESSION_BUS_ADDRESS`, UI launched outside a desktop session) Wails logs the error but leaves the service registered with a **nil `*dbus.Conn`**. The next send then nil-derefs deep inside godbus (`Conn.getSerial`), and because sends run on a Wails event-dispatch goroutine the panic is fatal to the whole process — observed as a "catastrophic failure" crash on a Linux Mint VM when an update toast fired (`tray_update.go sendUpdateNotification`). `recover()` turns it into a logged no-op. The `tray_session.go` plain-notification fallback keys off the returned error, so a recovered panic (returns nil) correctly skips the fallback — the bus is dead, the plain send would panic too. + ### Profile switching invariants `ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above. diff --git a/client/ui/main.go b/client/ui/main.go index c91f363ac..afe5f63dd 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -190,7 +190,20 @@ func main() { }) listenForShowSignal(context.Background(), tray) - daemonFeed.Watch(context.Background()) + // Start the daemon event feed only after Wails has run every service's + // ServiceStartup. The very first daemon SubscribeEvents message replays + // the cached state (status + available update) synchronously, which fans + // out through app.Event into the tray's update-state listener and fires an + // OS notification. If Watch ran before app.Run, that send could beat the + // notifications service's ServiceStartup — on Linux the Wails notifier + // connects to the session bus there, so its *dbus.Conn would still be nil + // and SendNotification would nil-deref (fatal panic on the event-dispatch + // goroutine; observed on Linux Mint). ApplicationStarted fires inside + // app.Run after the synchronous service-startup loop, so the bus is up by + // the time the first event lands. + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + daemonFeed.Watch(context.Background()) + }) if err := app.Run(); err != nil { log.Fatal(err) diff --git a/client/ui/tray.go b/client/ui/tray.go index dd54cfda5..e1d93e431 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -637,13 +637,11 @@ func (t *Tray) notify(title, body, id string) { if t.svc.Notifier == nil { return } - if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{ + _ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{ ID: id, Title: title, Body: body, - }); err != nil { - log.Debugf("notify %q: %v", title, err) - } + }) } // notifyError fires a generic "Error" notification for tray-driven action diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go new file mode 100644 index 000000000..1a4a030b6 --- /dev/null +++ b/client/ui/tray_notify.go @@ -0,0 +1,39 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +// sendFn is either NotificationService.SendNotification or +// SendNotificationWithActions — both share the same signature. +type sendFn func(notifications.NotificationOptions) error + +// safeSendNotification dispatches an OS notification, swallowing both errors +// and panics. OS toasts are best-effort: a missing or broken session bus must +// never crash the app. +// +// The panic guard is load-bearing on Linux. Wails' notifier connects to the +// session bus in its ServiceStartup; when that connect fails (headless box, +// no/unreachable DBUS_SESSION_BUS_ADDRESS, a UI launched outside a desktop +// session), Wails logs the error but leaves the service registered with a nil +// *dbus.Conn. The next SendNotification then nil-derefs deep inside +// godbus (Conn.getSerial) and, because the send runs on a Wails event-dispatch +// goroutine, the panic is fatal to the whole process rather than to one event +// listener. recover() turns that into a logged no-op. See +// notifications_linux.go in wails v3. +func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + defer func() { + if r := recover(); r != nil { + log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) + err = nil + } + }() + if err := send(opts); err != nil { + log.Errorf("notify %s: %v", what, err) + return err + } + return nil +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 55b7e6394..aa6643f73 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -224,16 +224,17 @@ func (t *Tray) notifySessionWarning(title, body string) { if t.svc.Notifier == nil { return } - err := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{ + err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{ ID: notifyIDSessionWarning, Title: title, Body: body, CategoryID: notifyCategorySessionWarning, }) if err != nil { - log.Debugf("notify session-warning with actions: %v", err) // Fall back to a plain notification so the user at least gets - // the warning text, even without buttons. + // the warning text, even without buttons. (A nil err here also + // covers the panic-recovered case, where the bus is dead and the + // plain fallback would fail too — so we correctly skip it.) t.notify(title, body, notifyIDSessionWarning) } } diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index 8fcf90da2..c3da3812f 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -202,13 +202,11 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) { if st.Enforced { body += u.loc.T("notify.update.enforcedSuffix") } - if err := u.notifier.SendNotification(notifications.NotificationOptions{ + _ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{ ID: notifyIDUpdatePrefix + st.Version, Title: u.loc.T("notify.update.title"), Body: body, - }); err != nil { - log.Debugf("send update notification: %v", err) - } + }) } // openProgressWindow points the main window at the /update progress page