diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 563ae7ff0..19215f5d8 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,7 +20,11 @@ jobs: uses: codespell-project/actions-codespell@v2 with: ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals - skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json + # Non-English UI translations trip codespell on real foreign words + # (de: "Sie", "oder", "ist"). Only en/common.json is the source of + # truth that should be spell-checked. Add each new locale dir here + # when a language is added under client/ui/i18n/locales/. + skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json,client/ui/i18n/locales/de/**,client/ui/i18n/locales/hu/** golangci: strategy: fail-fast: false diff --git a/client/internal/engine.go b/client/internal/engine.go index d2e0e41f3..99ae66673 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl" - "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" @@ -252,7 +251,19 @@ type Engine struct { exposeManager *expose.Manager - sessionWatcher *sessionwatch.Watcher + sessionWatcher sessionDeadlineWatcher +} + +// sessionDeadlineWatcher is the engine-facing surface of the SSO session +// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired +// in via newSessionWatcher, which is build-tagged so the js/wasm build links a +// no-op stub instead of pulling the full sessionwatch package (and its timer +// machinery) into the binary — the wasm client never runs the engine's +// session-warning flow. +type sessionDeadlineWatcher interface { + Update(deadline time.Time) error + Dismiss() + Close() } // Peer is an instance of the Connection Peer @@ -306,7 +317,7 @@ func NewEngine( // - T-WarningLead → interactive "Extend now / Dismiss" notification // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog, // suppressed when the user dismissed the earlier warning - engine.sessionWatcher = sessionwatch.New(engine.statusRecorder) + engine.sessionWatcher = newSessionWatcher(engine.statusRecorder) log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) return engine diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go new file mode 100644 index 000000000..a46d73f87 --- /dev/null +++ b/client/internal/engine_sessionwatch.go @@ -0,0 +1,16 @@ +//go:build !js + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// newSessionWatcher returns the real SSO session expiry watcher for every +// non-wasm build. The js/wasm build gets a no-op stub from +// engine_sessionwatch_js.go so the sessionwatch package (and its timer +// machinery) never links into the wasm binary. +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.New(recorder) +} diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go new file mode 100644 index 000000000..98a0c1da7 --- /dev/null +++ b/client/internal/engine_sessionwatch_js.go @@ -0,0 +1,39 @@ +//go:build js + +package internal + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The +// wasm client never runs the engine's session-warning flow (the interactive +// T-WarningLead notification and the T-FinalWarningLead fallback dialog live +// in the desktop UI), so linking the full sessionwatch package (timers, event +// composition) would only bloat the binary. +// +// It still mirrors the deadline into the status recorder so the SubscribeStatus +// / Status snapshot the UI consumes stays correct — only the timer-driven +// warnings are dropped. +type noopSessionWatcher struct { + recorder *peer.Status +} + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return noopSessionWatcher{recorder: recorder} +} + +// Update mirrors the real watcher's recorder propagation without the timers or +// sanity-check sentinels: a valid deadline is exposed on the status snapshot, +// the zero time clears it. +func (w noopSessionWatcher) Update(deadline time.Time) error { + if w.recorder != nil { + w.recorder.SetSessionExpiresAt(deadline) + } + return nil +} + +func (noopSessionWatcher) Dismiss() {} +func (noopSessionWatcher) Close() {} diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go index 454d3afa3..e2be0551c 100644 --- a/client/ssh/server/test.go +++ b/client/ssh/server/test.go @@ -1,3 +1,11 @@ +// This file is intentionally named test.go (not test_test.go) so the exported +// StartTestServer helper is visible to the ssh/proxy and ssh/client external +// test packages, not just this package's own tests. The //go:build !js tag +// keeps its "testing" import — and the whole testing/flag/regexp transitive +// chain it drags in — out of the wasm client, which links ssh/server through +// the engine but never runs Go tests under GOOS=js. +//go:build !js + package server import ( diff --git a/client/ui/main.go b/client/ui/main.go index a52148f4d..08544097b 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -57,6 +57,22 @@ func (s *stringList) Set(v string) error { return nil } +// registeredServices bundles the constructed services that registerServices +// binds to the Wails app, keeping the call site readable. +type registeredServices struct { + connection *services.Connection + authSession *authsession.Session + settings *services.Settings + networks *services.Networks + profiles *services.Profiles + update *services.Update + daemonFeed *services.DaemonFeed + notifier *notifications.NotificationService + profileSwitcher *services.ProfileSwitcher + bundle *i18n.Bundle + prefStore *preferences.Store +} + func init() { application.RegisterEvent[services.Status](services.EventStatusSnapshot) application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification) @@ -67,6 +83,98 @@ func init() { } func main() { + daemonAddr := parseFlagsAndInitLog() + conn := NewConn(daemonAddr) + + // tray is captured in the SingleInstance callback below; the var is + // declared before app.New so the closure has a stable reference. + var tray *Tray + app := newApplication(func() { + if tray != nil { + tray.ShowWindow() + } + }) + + settings := services.NewSettings(conn) + profiles := services.NewProfiles(conn) + // updater.Holder owns the typed update State. DaemonFeed pipes the + // daemon SubscribeEvents stream into it; the Update service is a thin + // Wails-bound facade over the holder plus the install RPCs. + updaterHolder := updater.NewHolder(app.Event) + update := services.NewUpdate(conn, updaterHolder) + daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder) + notifier := notifications.New() + + bundle, prefStore, localizer := buildI18n(app) + + // Connection lives after bundle + prefStore so it can localise daemon + // errors (services.NewConnection takes both as dependencies). + connection := services.NewConnection(conn, bundle, prefStore) + profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) + // authsession.Session owns the full extend + dismiss surface; the tray + // drives the "Extend now" action from the T-10 OS notification through + // this directly. The Wails-bound services.Session wraps only the subset + // the React frontend calls, so the generated TS surface stays minimal. + authSession := authsession.NewSession(conn) + networks := services.NewNetworks(conn) + + registerServices(app, conn, registeredServices{ + connection: connection, + authSession: authSession, + settings: settings, + networks: networks, + profiles: profiles, + update: update, + daemonFeed: daemonFeed, + notifier: notifier, + profileSwitcher: profileSwitcher, + bundle: bundle, + prefStore: prefStore, + }) + + window := newMainWindow(app, prefStore) + + // Settings is created eagerly (hidden) inside NewWindowManager so the + // first click on the gear paints instantly and the React side keeps + // per-tab state across reopens. The other auxiliary windows + // (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close + // so they don't linger as hidden windows that Wails's macOS dock-reopen + // handler would pop back up. + windowManager := services.NewWindowManager(app, window) + app.RegisterService(application.NewService(windowManager)) + + // Register an in-process StatusNotifierWatcher so the tray works on + // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the + // AppIndicator extension) that don't ship one themselves. No-op on + // non-Linux platforms. Must run before NewTray so the Wails systray's + // RegisterStatusNotifierItem call hits a watcher we control. + startStatusNotifierWatcher() + + tray = NewTray(app, window, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Networks: networks, + DaemonFeed: daemonFeed, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, + Session: authSession, + Localizer: localizer, + }) + listenForShowSignal(context.Background(), tray) + + daemonFeed.Watch(context.Background()) + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} + +// parseFlagsAndInitLog parses the CLI flags, initialises the logger, and +// returns the resolved daemon gRPC address. +func parseFlagsAndInitLog() string { daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") logFiles := &stringList{values: []string{"console"}} flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console.") @@ -76,13 +184,12 @@ func main() { if err := util.InitLog(*logLevel, logFiles.values...); err != nil { log.Fatalf("init log: %v", err) } + return *daemonAddr +} - conn := NewConn(*daemonAddr) - - // tray is captured in the SingleInstance callback below; the var is - // declared before app.New so the closure has a stable reference. - var tray *Tray - +// newApplication constructs the Wails application. onSecondInstance is invoked +// when a second process launches under the same SingleInstance UniqueID. +func newApplication(onSecondInstance func()) *application.App { // On macOS, application.Options.Icon is fed into NSApplication's // setApplicationIconImage at startup, which would override the bundle // icon (Assets.car / icons.icns) the OS already picked. We want the @@ -92,7 +199,7 @@ func main() { appIcon = nil } - app := application.New(application.Options{ + return application.New(application.Options{ // Windows uses Name as the AppUserModelID for toast notifications // (see notifications_windows.go: cfg.Name -> wn.appName -> AppID) // and as the registry path under HKCU\Software\Classes\AppUserModelId\. @@ -116,23 +223,16 @@ func main() { SingleInstance: &application.SingleInstanceOptions{ UniqueID: "io.netbird.ui", OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { - if tray != nil { - tray.ShowWindow() - } + onSecondInstance() }, }, }) +} - settings := services.NewSettings(conn) - profiles := services.NewProfiles(conn) - // updater.Holder owns the typed update State. DaemonFeed pipes the - // daemon SubscribeEvents stream into it; the Update service is a thin - // Wails-bound facade over the holder plus the install RPCs. - updaterHolder := updater.NewHolder(app.Event) - update := services.NewUpdate(conn, updaterHolder) - daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder) - notifier := notifications.New() - +// buildI18n constructs the domain-layer i18n bundle, the preferences store, +// and the tray localizer. The Bundle satisfies preferences.LanguageValidator +// so SetLanguage rejects codes that have no shipped translation. +func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) { // localesFS reroots the embedded tree at the locales directory itself // so the bundle sees _index.json and /common.json at the top // level (the //go:embed path is rooted at the package, not the leaf @@ -141,9 +241,6 @@ func main() { if err != nil { log.Fatalf("locate locales fs: %v", err) } - // Build the domain layer first, then wrap it in the Wails-bound - // services. The Bundle satisfies preferences.LanguageValidator so - // SetLanguage rejects codes that have no shipped translation. bundle, err := i18n.NewBundle(localesFS) if err != nil { log.Fatalf("init i18n bundle: %v", err) @@ -152,33 +249,32 @@ func main() { if err != nil { log.Fatalf("init preferences store: %v", err) } - localizer := NewLocalizer(bundle, prefStore) + return bundle, prefStore, NewLocalizer(bundle, prefStore) +} - // Connection lives after bundle + prefStore so it can localise daemon - // errors (services.NewConnection takes both as dependencies). - connection := services.NewConnection(conn, bundle, prefStore) - profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) - - app.RegisterService(application.NewService(connection)) - // authsession.Session owns the full extend + dismiss surface; the tray - // drives the "Extend now" action from the T-10 OS notification through - // this directly. The Wails-bound services.Session wraps only the subset - // the React frontend calls, so the generated TS surface stays minimal. - authSession := authsession.NewSession(conn) - app.RegisterService(application.NewService(services.NewSession(authSession))) - app.RegisterService(application.NewService(settings)) - networks := services.NewNetworks(conn) - app.RegisterService(application.NewService(networks)) +// registerServices binds every Wails-facing service onto the application. +// Services constructed inline here (Session, Forwarding, Debug, I18n, +// Preferences) have no other caller; the rest arrive already built so the +// tray and feed loops can share the same instances. +func registerServices(app *application.App, conn *Conn, s registeredServices) { + app.RegisterService(application.NewService(s.connection)) + app.RegisterService(application.NewService(services.NewSession(s.authSession))) + app.RegisterService(application.NewService(s.settings)) + app.RegisterService(application.NewService(s.networks)) app.RegisterService(application.NewService(services.NewForwarding(conn))) - app.RegisterService(application.NewService(profiles)) + app.RegisterService(application.NewService(s.profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) - app.RegisterService(application.NewService(update)) - app.RegisterService(application.NewService(daemonFeed)) - app.RegisterService(application.NewService(notifier)) - app.RegisterService(application.NewService(profileSwitcher)) - app.RegisterService(application.NewService(services.NewI18n(bundle))) - app.RegisterService(application.NewService(services.NewPreferences(prefStore))) + app.RegisterService(application.NewService(s.update)) + app.RegisterService(application.NewService(s.daemonFeed)) + app.RegisterService(application.NewService(s.notifier)) + app.RegisterService(application.NewService(s.profileSwitcher)) + app.RegisterService(application.NewService(services.NewI18n(s.bundle))) + app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) +} +// newMainWindow creates the hidden main window, sized to the user's last view +// mode, and installs the hide-on-close and macOS dock-reopen hooks. +func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow { // Open the main window at the width matching the user's last view // choice so an Advanced-mode user doesn't see the window pop from 380px // to 900px on every launch. Height is the same in both modes. @@ -228,40 +324,5 @@ func main() { }) } - // Settings is created eagerly (hidden) inside NewWindowManager so the - // first click on the gear paints instantly and the React side keeps - // per-tab state across reopens. The other auxiliary windows - // (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close - // so they don't linger as hidden windows that Wails's macOS dock-reopen - // handler would pop back up. - windowManager := services.NewWindowManager(app, window) - app.RegisterService(application.NewService(windowManager)) - - // Register an in-process StatusNotifierWatcher so the tray works on - // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the - // AppIndicator extension) that don't ship one themselves. No-op on - // non-Linux platforms. Must run before NewTray so the Wails systray's - // RegisterStatusNotifierItem call hits a watcher we control. - startStatusNotifierWatcher() - - tray = NewTray(app, window, TrayServices{ - Connection: connection, - Settings: settings, - Profiles: profiles, - Networks: networks, - DaemonFeed: daemonFeed, - Notifier: notifier, - Update: update, - ProfileSwitcher: profileSwitcher, - WindowManager: windowManager, - Session: authSession, - Localizer: localizer, - }) - listenForShowSignal(context.Background(), tray) - - daemonFeed.Watch(context.Background()) - - if err := app.Run(); err != nil { - log.Fatal(err) - } + return window } diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 5d7d7103b..19c28a831 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -334,38 +334,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { } op := func() error { - cli, err := s.conn.Client() - if err != nil { - emitUnavailable() - return fmt.Errorf("get client: %w", err) - } - stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) - if err != nil { - if isDaemonUnreachable(err) { - emitUnavailable() - } - return fmt.Errorf("subscribe status: %w", err) - } - for { - resp, err := stream.Recv() - if err != nil { - if ctx.Err() != nil { - return ctx.Err() - } - if isDaemonUnreachable(err) { - emitUnavailable() - } - return fmt.Errorf("status stream recv: %w", err) - } - unavailable = false - st := statusFromProto(resp) - log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) - if s.shouldSuppress(st) { - log.Debugf("suppressing status=%q during profile switch", st.Status) - continue - } - s.emitter.Emit(EventStatusSnapshot, st) - } + return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable) } if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { @@ -373,6 +342,58 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { } } +// subscribeAndStreamStatus is one attempt of the status backoff loop: open the +// SubscribeStatus stream and re-emit every snapshot until it errors. Returns a +// wrapped error so backoff retries; a daemon-unreachable failure also flips the +// synthetic-unavailable signal (once per outage, guarded by *unavailable). +func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { + cli, err := s.conn.Client() + if err != nil { + emitUnavailable() + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("subscribe status: %w", err) + } + for { + resp, err := stream.Recv() + if err != nil { + return s.handleStatusRecvErr(ctx, err, emitUnavailable) + } + *unavailable = false + s.emitStatus(statusFromProto(resp)) + } +} + +// handleStatusRecvErr maps a SubscribeStatus stream.Recv error into the +// backoff loop's return value: ctx cancellation stops the loop, an +// unreachable socket flips the synthetic-unavailable signal, everything +// else is a retryable wrapped error. +func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error { + if ctx.Err() != nil { + return ctx.Err() + } + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("status stream recv: %w", err) +} + +// emitStatus pushes a fresh snapshot to the frontend, dropping the transient +// stale-Connected / Idle pushes that occur mid profile switch. +func (s *DaemonFeed) emitStatus(st Status) { + log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) + if s.shouldSuppress(st) { + log.Debugf("suppressing status=%q during profile switch", st.Status) + return + } + s.emitter.Emit(EventStatusSnapshot, st) +} + // toastStreamLoop subscribes to the daemon's SubscribeEvents RPC and // re-emits every SystemEvent on the Wails event bus. The downstream // consumers turn these into OS notifications, populate the Recent @@ -394,32 +415,7 @@ func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { }, ctx) op := func() error { - cli, err := s.conn.Client() - if err != nil { - return fmt.Errorf("get client: %w", err) - } - stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{}) - if err != nil { - return fmt.Errorf("subscribe: %w", err) - } - for { - ev, err := stream.Recv() - if err != nil { - if ctx.Err() != nil { - return ctx.Err() - } - return fmt.Errorf("stream recv: %w", err) - } - se := systemEventFromProto(ev) - log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) - s.emitter.Emit(EventDaemonNotification, se) - if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { - s.emitter.Emit(EventSessionWarning, warn) - } - if s.updater != nil { - s.updater.OnSystemEvent(ev) - } - } + return s.subscribeAndStreamEvents(ctx) } if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { @@ -427,6 +423,45 @@ func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { } } +// subscribeAndStreamEvents is one attempt of the event backoff loop: open the +// SubscribeEvents stream and fan out every SystemEvent until it errors. ctx +// cancellation stops the loop; any other error is wrapped so backoff retries. +func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{}) + if err != nil { + return fmt.Errorf("subscribe: %w", err) + } + for { + ev, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("stream recv: %w", err) + } + s.dispatchSystemEvent(ev) + } +} + +// dispatchSystemEvent fans one daemon SystemEvent out to the frontend +// notification stream, the typed session-warning event (when the metadata +// carries one), and the updater holder (when present). +func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { + se := systemEventFromProto(ev) + log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) + s.emitter.Emit(EventDaemonNotification, se) + if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { + s.emitter.Emit(EventSessionWarning, warn) + } + if s.updater != nil { + s.updater.OnSystemEvent(ev) + } +} + func statusFromProto(resp *proto.StatusResponse) Status { full := resp.GetFullStatus() mgmt := full.GetManagementState() diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go index 6a509bf1a..294dc61a4 100644 --- a/client/ui/tray_status.go +++ b/client/ui/tray_status.go @@ -41,26 +41,7 @@ func (t *Tray) applyStatus(st services.Status) { sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) - // Consume the SSO auto-handoff flag armed by handleConnect. Trigger - // the browser-login flow on a Connect → NeedsLogin transition so the - // user doesn't need to click Connect a second time. Clear it on any - // other terminal state — including Connecting bursts that resolve to - // Connected / Idle / LoginFailed / DaemonUnavailable — so a stale - // flag can't fire weeks later when the daemon happens to flip. - triggerLogin := false - if t.pendingConnectLogin { - switch { - case strings.EqualFold(st.Status, services.StatusNeedsLogin): - triggerLogin = true - t.pendingConnectLogin = false - case strings.EqualFold(st.Status, services.StatusConnected), - strings.EqualFold(st.Status, services.StatusIdle), - strings.EqualFold(st.Status, services.StatusLoginFailed), - strings.EqualFold(st.Status, services.StatusSessionExpired), - strings.EqualFold(st.Status, services.StatusDaemonUnavailable): - t.pendingConnectLogin = false - } - } + triggerLogin := t.consumePendingConnectLogin(st.Status) daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion t.connected = connected @@ -79,57 +60,7 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() - daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) - connecting := strings.EqualFold(st.Status, services.StatusConnecting) - if t.statusItem != nil { - // Label-only: row is informational (no OnClick). Enablement - // is platform-dependent via statusRowEnabled — Windows - // keeps it enabled so the Win32 disabled-state mask does - // not desaturate the coloured dot; macOS/Linux disable it. - // Swap the displayed text so the user sees a familiar - // phrase instead of the raw daemon enum. - t.statusItem.SetLabel(t.loc.StatusLabel(st.Status)) - t.statusItem.SetEnabled(statusRowEnabled()) - t.applyStatusIndicator(st.Status) - } - if t.upItem != nil { - // Connect stays visible/clickable in NeedsLogin/SessionExpired/ - // LoginFailed too — the daemon's Up RPC kicks off the SSO flow - // when re-auth is required, mirroring the legacy Fyne client - // where the same button drove the initial and the re-login - // paths. Hidden only when the action would be a no-op (tunnel - // up, daemon mid-connect — Disconnect takes the slot) or - // would fail with no useful side effect (daemon unreachable). - t.upItem.SetHidden(connected || connecting || daemonUnavailable) - t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) - } - if t.downItem != nil { - // Disconnect is the abort path while the daemon is still - // retrying the management dial — without it the user has no - // way to stop the loop short of killing the daemon. - t.downItem.SetHidden(!connected && !connecting) - t.downItem.SetEnabled(connected || connecting) - } - // Exit Node parent-item enablement (greyed unless the tunnel is up - // AND at least one candidate exists) is owned by refreshExitNodes, - // triggered below on this same transition. Settings just needs the - // daemon socket reachable. - if t.settingsItem != nil { - t.settingsItem.SetEnabled(!daemonUnavailable) - } - if t.profileSubmenuItem != nil { - t.profileSubmenuItem.SetEnabled(!daemonUnavailable) - } - // Refresh the Profiles submenu on every status-text transition: the - // daemon does not emit an active-profile event, so the startup race - // (UI loads profiles before autoconnect picks the persisted profile) - // and a CLI "profile select && up" both surface here. Fired AFTER - // all SetHidden/SetEnabled writes on the static menu items above so - // loadProfiles' SetMenu rebuild (which clearMenu+processMenu the - // entire NSMenu and re-assigns item.impl) cannot race those - // writes — the Wails 3 alpha menu API is not goroutine-safe and - // reads item.disabled/item.hidden at NSMenuItem construction time. - go t.loadProfiles() + t.refreshMenuItemsForStatus(st, connected) } // Re-fetch the selectable exit-node list whenever the daemon's routed- // networks revision bumps (a route candidate added/removed, or a selection @@ -150,6 +81,89 @@ func (t *Tray) applyStatus(st services.Status) { t.applySessionExpiry(st.SessionExpiresAt, connected) } +// consumePendingConnectLogin acts on the SSO auto-handoff flag armed by +// handleConnect. It returns true (and clears the flag) when the daemon +// reached NeedsLogin, signalling the browser-login flow should start so the +// user doesn't need to click Connect a second time. The flag is also cleared +// on any other terminal state — including Connecting bursts that resolve to +// Connected / Idle / LoginFailed / DaemonUnavailable — so a stale flag can't +// fire weeks later when the daemon happens to flip. Must be called with +// statusMu held. +func (t *Tray) consumePendingConnectLogin(status string) bool { + if !t.pendingConnectLogin { + return false + } + switch { + case strings.EqualFold(status, services.StatusNeedsLogin): + t.pendingConnectLogin = false + return true + case strings.EqualFold(status, services.StatusConnected), + strings.EqualFold(status, services.StatusIdle), + strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, services.StatusSessionExpired), + strings.EqualFold(status, services.StatusDaemonUnavailable): + t.pendingConnectLogin = false + } + return false +} + +// refreshMenuItemsForStatus updates the status row, Connect/Disconnect +// enablement, Settings/Profiles gating, and Profiles submenu on a status-text +// transition (called from applyStatus only when iconChanged). +func (t *Tray) refreshMenuItemsForStatus(st services.Status, connected bool) { + daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) + connecting := strings.EqualFold(st.Status, services.StatusConnecting) + if t.statusItem != nil { + // Label-only: row is informational (no OnClick). Enablement + // is platform-dependent via statusRowEnabled — Windows + // keeps it enabled so the Win32 disabled-state mask does + // not desaturate the coloured dot; macOS/Linux disable it. + // Swap the displayed text so the user sees a familiar + // phrase instead of the raw daemon enum. + t.statusItem.SetLabel(t.loc.StatusLabel(st.Status)) + t.statusItem.SetEnabled(statusRowEnabled()) + t.applyStatusIndicator(st.Status) + } + if t.upItem != nil { + // Connect stays visible/clickable in NeedsLogin/SessionExpired/ + // LoginFailed too — the daemon's Up RPC kicks off the SSO flow + // when re-auth is required, mirroring the legacy Fyne client + // where the same button drove the initial and the re-login + // paths. Hidden only when the action would be a no-op (tunnel + // up, daemon mid-connect — Disconnect takes the slot) or + // would fail with no useful side effect (daemon unreachable). + t.upItem.SetHidden(connected || connecting || daemonUnavailable) + t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) + } + if t.downItem != nil { + // Disconnect is the abort path while the daemon is still + // retrying the management dial — without it the user has no + // way to stop the loop short of killing the daemon. + t.downItem.SetHidden(!connected && !connecting) + t.downItem.SetEnabled(connected || connecting) + } + // Exit Node parent-item enablement (greyed unless the tunnel is up + // AND at least one candidate exists) is owned by refreshExitNodes, + // triggered by applyStatus on this same transition. Settings just needs + // the daemon socket reachable. + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable) + } + // Refresh the Profiles submenu on every status-text transition: the + // daemon does not emit an active-profile event, so the startup race + // (UI loads profiles before autoconnect picks the persisted profile) + // and a CLI "profile select && up" both surface here. Fired AFTER + // all SetHidden/SetEnabled writes on the static menu items above so + // loadProfiles' SetMenu rebuild (which clearMenu+processMenu the + // entire NSMenu and re-assigns item.impl) cannot race those + // writes — the Wails 3 alpha menu API is not goroutine-safe and + // reads item.disabled/item.hidden at NSMenuItem construction time. + go t.loadProfiles() +} + // applyStatusIndicator sets the small coloured dot shown on the status // menu entry. The dot mirrors the tray icon's state through a fixed // palette: green for Connected, yellow for Connecting, blue for the diff --git a/client/ui/tray_watcher_other.go b/client/ui/tray_watcher_other.go index aa8fc8ac7..23adc6d4d 100644 --- a/client/ui/tray_watcher_other.go +++ b/client/ui/tray_watcher_other.go @@ -2,5 +2,16 @@ package main -// startStatusNotifierWatcher is a no-op on non-Linux platforms. -func startStatusNotifierWatcher() {} +// startStatusNotifierWatcher is a no-op on non-Linux platforms (and on +// linux/386, which excludes the cgo XEmbed host). +// +// The in-process org.kde.StatusNotifierWatcher + XEmbed bridge in +// tray_watcher_linux.go only exists to rescue the tray on minimal Linux WMs +// that ship no SNI watcher of their own. macOS and Windows have a native +// system tray that Wails talks to directly, so there is nothing to register +// here — the function body is intentionally empty rather than missing so +// main.go can call startStatusNotifierWatcher() unconditionally across all +// build targets. +func startStatusNotifierWatcher() { + // Intentionally empty: see the doc comment above. +} diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go index 3c94848b7..f90e7de75 100644 --- a/client/ui/xembed_host_linux.go +++ b/client/ui/xembed_host_linux.go @@ -51,8 +51,6 @@ type dbusMenuLayout struct { Children []dbus.Variant } - - // xembedHost manages one XEmbed tray icon for an SNI item. type xembedHost struct { conn *dbus.Conn @@ -314,66 +312,51 @@ func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil { continue } - - mi := menuItemInfo{ - id: child.ID, - enabled: true, + if mi, ok := h.menuItemFromLayout(child); ok { + items = append(items, mi) } - - if v, ok := child.Properties["type"]; ok { - if s, ok := v.Value().(string); ok && s == "separator" { - mi.isSeparator = true - items = append(items, mi) - continue - } - } - - if v, ok := child.Properties["label"]; ok { - if s, ok := v.Value().(string); ok { - mi.label = s - } - } - - if v, ok := child.Properties["enabled"]; ok { - if b, ok := v.Value().(bool); ok { - mi.enabled = b - } - } - - if v, ok := child.Properties["visible"]; ok { - if b, ok := v.Value().(bool); ok && !b { - continue // skip hidden items - } - } - - if v, ok := child.Properties["toggle-type"]; ok { - if s, ok := v.Value().(string); ok && s == "checkmark" { - mi.isCheck = true - } - } - - if v, ok := child.Properties["toggle-state"]; ok { - if n, ok := v.Value().(int32); ok && n == 1 { - mi.checked = true - } - } - - // Recurse into nested submenus. The dbusmenu spec marks a folder - // item with children-display=="submenu"; the children are already - // in child.Children because GetLayout was called with - // recursionDepth=-1 (all levels). - if v, ok := child.Properties["children-display"]; ok { - if s, ok := v.Value().(string); ok && s == "submenu" { - mi.children = h.flattenMenu(child) - } - } - - items = append(items, mi) } return items } +// menuItemFromLayout decodes one dbusmenu child node into a menuItemInfo, +// recursing into submenus. The bool return is false when the item is hidden +// (visible=false) and should be dropped from the parent's list. +func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, bool) { + mi := menuItemInfo{id: child.ID, enabled: true} + + if propString(child.Properties, "type") == "separator" { + mi.isSeparator = true + return mi, true + } + + if vis, ok := propBool(child.Properties, "visible"); ok && !vis { + return menuItemInfo{}, false + } + + mi.label = propString(child.Properties, "label") + if en, ok := propBool(child.Properties, "enabled"); ok { + mi.enabled = en + } + if propString(child.Properties, "toggle-type") == "checkmark" { + mi.isCheck = true + } + if n, ok := propInt32(child.Properties, "toggle-state"); ok && n == 1 { + mi.checked = true + } + + // Recurse into nested submenus. The dbusmenu spec marks a folder + // item with children-display=="submenu"; the children are already + // in child.Children because GetLayout was called with + // recursionDepth=-1 (all levels). + if propString(child.Properties, "children-display") == "submenu" { + mi.children = h.flattenMenu(child) + } + + return mi, true +} + func (h *xembedHost) sendMenuEvent(id int32) { menuPath := dbus.ObjectPath("/StatusNotifierMenu") menuObj := h.conn.Object(h.busName, menuPath) @@ -442,3 +425,36 @@ func boolToInt(b bool) C.int { } return 0 } + +// propString returns the string value of a dbusmenu property, or "" when the +// property is absent or not a string. +func propString(props map[string]dbus.Variant, key string) string { + if v, ok := props[key]; ok { + if s, ok := v.Value().(string); ok { + return s + } + } + return "" +} + +// propBool returns the bool value of a dbusmenu property; ok is false when the +// property is absent or not a bool. +func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { + if v, present := props[key]; present { + if b, isBool := v.Value().(bool); isBool { + return b, true + } + } + return false, false +} + +// propInt32 returns the int32 value of a dbusmenu property; ok is false when +// the property is absent or not an int32. +func propInt32(props map[string]dbus.Variant, key string) (value int32, ok bool) { + if v, present := props[key]; present { + if n, isInt := v.Value().(int32); isInt { + return n, true + } + } + return 0, false +}