Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor

This commit is contained in:
Eduard Gert
2026-05-29 15:40:41 +02:00
10 changed files with 488 additions and 273 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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() {}

View File

@@ -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 (

View File

@@ -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 <lang>/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
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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.
}

View File

@@ -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
}