mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 20:49:56 +00:00
Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
This commit is contained in:
6
.github/workflows/golangci-lint.yml
vendored
6
.github/workflows/golangci-lint.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
16
client/internal/engine_sessionwatch.go
Normal file
16
client/internal/engine_sessionwatch.go
Normal 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)
|
||||
}
|
||||
39
client/internal/engine_sessionwatch_js.go
Normal file
39
client/internal/engine_sessionwatch_js.go
Normal 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() {}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user