From 8ad0cf8e5f6c11f9f9f908e73d64cc47721d2279 Mon Sep 17 00:00:00 2001 From: Patrick Fischer Date: Sat, 21 Feb 2026 00:05:30 +0800 Subject: [PATCH] screensaver: emit ActiveChanged on lock/unlock (#1761) --- core/internal/server/freedesktop/constants.go | 4 + core/internal/server/freedesktop/manager.go | 6 + .../server/freedesktop/screensaver.go | 199 +++++++++++++----- .../server/freedesktop/screensaver_test.go | 102 +++++++++ core/internal/server/freedesktop/types.go | 23 +- core/internal/server/server.go | 30 +++ 6 files changed, 297 insertions(+), 67 deletions(-) create mode 100644 core/internal/server/freedesktop/screensaver_test.go diff --git a/core/internal/server/freedesktop/constants.go b/core/internal/server/freedesktop/constants.go index 125d56db..12db87bf 100644 --- a/core/internal/server/freedesktop/constants.go +++ b/core/internal/server/freedesktop/constants.go @@ -16,4 +16,8 @@ const ( dbusScreensaverPath = "/ScreenSaver" dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver" dbusScreensaverInterface = "org.freedesktop.ScreenSaver" + + dbusGnomeScreensaverName = "org.gnome.ScreenSaver" + dbusGnomeScreensaverPath = "/org/gnome/ScreenSaver" + dbusGnomeScreensaverInterface = "org.gnome.ScreenSaver" ) diff --git a/core/internal/server/freedesktop/manager.go b/core/internal/server/freedesktop/manager.go index 2a1f1f1d..054f859e 100644 --- a/core/internal/server/freedesktop/manager.go +++ b/core/internal/server/freedesktop/manager.go @@ -191,6 +191,12 @@ func (m *Manager) Close() { return true }) + m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool { + close(ch) + m.screensaverSubscribers.Delete(key) + return true + }) + if m.systemConn != nil { m.systemConn.Close() } diff --git a/core/internal/server/freedesktop/screensaver.go b/core/internal/server/freedesktop/screensaver.go index a86111cb..21f7d9ed 100644 --- a/core/internal/server/freedesktop/screensaver.go +++ b/core/internal/server/freedesktop/screensaver.go @@ -1,6 +1,7 @@ package freedesktop import ( + "fmt" "path/filepath" "strings" "sync/atomic" @@ -15,45 +16,9 @@ type screensaverHandler struct { manager *Manager } -func (m *Manager) initializeScreensaver() error { - if m.sessionConn == nil { - m.stateMutex.Lock() - m.state.Screensaver.Available = false - m.stateMutex.Unlock() - return nil - } - - reply, err := m.sessionConn.RequestName(dbusScreensaverName, dbus.NameFlagDoNotQueue) - if err != nil { - log.Warnf("Failed to request screensaver name: %v", err) - m.stateMutex.Lock() - m.state.Screensaver.Available = false - m.stateMutex.Unlock() - return nil - } - - if reply != dbus.RequestNameReplyPrimaryOwner { - log.Warnf("Screensaver name already owned by another process") - m.stateMutex.Lock() - m.state.Screensaver.Available = false - m.stateMutex.Unlock() - return nil - } - - handler := &screensaverHandler{manager: m} - - if err := m.sessionConn.Export(handler, dbusScreensaverPath, dbusScreensaverInterface); err != nil { - log.Warnf("Failed to export screensaver on %s: %v", dbusScreensaverPath, err) - return nil - } - - if err := m.sessionConn.Export(handler, dbusScreensaverPath2, dbusScreensaverInterface); err != nil { - log.Warnf("Failed to export screensaver on %s: %v", dbusScreensaverPath2, err) - return nil - } - - screensaverIface := introspect.Interface{ - Name: dbusScreensaverInterface, +func screensaverIntrospectIface(ifaceName string) introspect.Interface { + return introspect.Interface{ + Name: ifaceName, Methods: []introspect.Method{ { Name: "Inhibit", @@ -69,40 +34,110 @@ func (m *Manager) initializeScreensaver() error { {Name: "cookie", Type: "u", Direction: "in"}, }, }, + { + Name: "GetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "out"}, + }, + }, + { + Name: "SetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "in"}, + }, + }, + { + Name: "Lock", + }, }, + Signals: []introspect.Signal{ + { + Name: "ActiveChanged", + Args: []introspect.Arg{ + {Name: "new_value", Type: "b"}, + }, + }, + }, + } +} + +func (m *Manager) initializeScreensaver() error { + if m.sessionConn == nil { + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil } - introNode := &introspect.Node{ - Name: dbusScreensaverPath, - Interfaces: []introspect.Interface{ - introspect.IntrospectData, - screensaverIface, - }, - } - if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode), dbusScreensaverPath, "org.freedesktop.DBus.Introspectable"); err != nil { - log.Warnf("Failed to export introspectable on %s: %v", dbusScreensaverPath, err) + handler := &screensaverHandler{manager: m} + + // Try to claim org.freedesktop.ScreenSaver (may fail if the compositor + // or another process already owns this name). + if reply, err := m.sessionConn.RequestName(dbusScreensaverName, dbus.NameFlagDoNotQueue); err != nil { + log.Warnf("Failed to request screensaver name %s: %v", dbusScreensaverName, err) + } else if reply != dbus.RequestNameReplyPrimaryOwner { + log.Warnf("Screensaver name %s already owned by another process", dbusScreensaverName) + } else if err := m.exportScreensaverOnPaths(handler, dbusScreensaverInterface, + dbusScreensaverPath, dbusScreensaverPath2); err != nil { + log.Warnf("Failed to export freedesktop screensaver: %v", err) + } else { + m.screensaverFreedesktopClaimed = true + log.Infof("Claimed %s on session bus", dbusScreensaverName) } - introNode2 := &introspect.Node{ - Name: dbusScreensaverPath2, - Interfaces: []introspect.Interface{ - introspect.IntrospectData, - screensaverIface, - }, + // Try to claim org.gnome.ScreenSaver independently as a fallback. + if reply, err := m.sessionConn.RequestName(dbusGnomeScreensaverName, dbus.NameFlagDoNotQueue); err != nil { + log.Warnf("Failed to request screensaver name %s: %v", dbusGnomeScreensaverName, err) + } else if reply != dbus.RequestNameReplyPrimaryOwner { + log.Warnf("Screensaver name %s already owned by another process", dbusGnomeScreensaverName) + } else if err := m.exportScreensaverOnPaths(handler, dbusGnomeScreensaverInterface, + dbusGnomeScreensaverPath); err != nil { + log.Warnf("Failed to export gnome screensaver: %v", err) + } else { + m.screensaverGnomeClaimed = true + log.Infof("Claimed %s on session bus", dbusGnomeScreensaverName) } - if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode2), dbusScreensaverPath2, "org.freedesktop.DBus.Introspectable"); err != nil { - log.Warnf("Failed to export introspectable on %s: %v", dbusScreensaverPath2, err) + + if !m.screensaverFreedesktopClaimed && !m.screensaverGnomeClaimed { + log.Warn("No screensaver interface could be claimed") + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil } go m.watchPeerDisconnects() m.stateMutex.Lock() m.state.Screensaver.Available = true + m.state.Screensaver.Active = false m.state.Screensaver.Inhibited = false m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{} m.stateMutex.Unlock() - log.Info("Screensaver inhibit listener initialized") + log.Info("Screensaver listener initialized") + return nil +} + +// exportScreensaverOnPaths exports the handler and introspection on the given +// paths under the specified interface name. +func (m *Manager) exportScreensaverOnPaths(handler *screensaverHandler, ifaceName string, paths ...dbus.ObjectPath) error { + iface := screensaverIntrospectIface(ifaceName) + for _, path := range paths { + if err := m.sessionConn.Export(handler, path, ifaceName); err != nil { + return fmt.Errorf("export handler on %s: %w", path, err) + } + node := &introspect.Node{ + Name: string(path), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + iface, + }, + } + if err := m.sessionConn.Export(introspect.NewIntrospectable(node), path, "org.freedesktop.DBus.Introspectable"); err != nil { + log.Warnf("Failed to export introspectable on %s: %v", path, err) + } + } return nil } @@ -268,3 +303,53 @@ func (m *Manager) NotifyScreensaverSubscribers() { return true }) } + +func (h *screensaverHandler) GetActive() (bool, *dbus.Error) { + h.manager.stateMutex.RLock() + active := h.manager.state.Screensaver.Active + h.manager.stateMutex.RUnlock() + return active, nil +} + +func (h *screensaverHandler) SetActive(active bool) *dbus.Error { + h.manager.SetScreenLockActive(active) + return nil +} + +func (h *screensaverHandler) Lock() *dbus.Error { + h.manager.SetScreenLockActive(true) + return nil +} + +// SetScreenLockActive updates the screensaver active (locked) state and emits +// ActiveChanged on all claimed session bus interfaces. +func (m *Manager) SetScreenLockActive(active bool) { + m.stateMutex.Lock() + changed := m.state.Screensaver.Active != active + m.state.Screensaver.Active = active + m.stateMutex.Unlock() + + if !changed { + return + } + + log.Infof("Screen lock active changed: %v", active) + + if m.sessionConn != nil { + if m.screensaverFreedesktopClaimed { + if err := m.sessionConn.Emit(dbusScreensaverPath, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath, err) + } + if err := m.sessionConn.Emit(dbusScreensaverPath2, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath2, err) + } + } + if m.screensaverGnomeClaimed { + if err := m.sessionConn.Emit(dbusGnomeScreensaverPath, dbusGnomeScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusGnomeScreensaverPath, err) + } + } + } + + m.NotifyScreensaverSubscribers() +} diff --git a/core/internal/server/freedesktop/screensaver_test.go b/core/internal/server/freedesktop/screensaver_test.go new file mode 100644 index 00000000..324c17ec --- /dev/null +++ b/core/internal/server/freedesktop/screensaver_test.go @@ -0,0 +1,102 @@ +package freedesktop + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSetScreenLockActive_ChangesState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.False(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(true) + assert.True(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(false) + assert.False(t, manager.GetScreensaverState().Active) +} + +func TestSetScreenLockActive_NoChangeNoDuplicate(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + // Setting to same value should not notify + manager.SetScreenLockActive(false) + + select { + case <-ch: + t.Fatal("should not have received notification for no-change") + case <-time.After(50 * time.Millisecond): + // Expected: no notification + } +} + +func TestSetScreenLockActive_NotifiesSubscribers(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + manager.SetScreenLockActive(true) + + select { + case state := <-ch: + assert.True(t, state.Active) + case <-time.After(time.Second): + t.Fatal("timeout waiting for subscriber notification") + } +} + +func TestSetScreenLockActive_NilSessionConn(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.NotPanics(t, func() { + manager.SetScreenLockActive(true) + }) + assert.True(t, manager.GetScreensaverState().Active) +} + +func TestGetActive_ReturnsCurrentState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: true}, + }, + stateMutex: sync.RWMutex{}, + } + + handler := &screensaverHandler{manager: manager} + active, dbusErr := handler.GetActive() + assert.Nil(t, dbusErr) + assert.True(t, active) +} + +func TestScreensaverState_ActiveDefaultsFalse(t *testing.T) { + state := ScreensaverState{} + assert.False(t, state.Active) +} diff --git a/core/internal/server/freedesktop/types.go b/core/internal/server/freedesktop/types.go index 54cb35e9..0a9517ab 100644 --- a/core/internal/server/freedesktop/types.go +++ b/core/internal/server/freedesktop/types.go @@ -39,6 +39,7 @@ type ScreensaverInhibitor struct { type ScreensaverState struct { Available bool `json:"available"` + Active bool `json:"active"` Inhibited bool `json:"inhibited"` Inhibitors []ScreensaverInhibitor `json:"inhibitors"` } @@ -50,14 +51,16 @@ type FreedeskState struct { } type Manager struct { - state *FreedeskState - stateMutex sync.RWMutex - systemConn *dbus.Conn - sessionConn *dbus.Conn - accountsObj dbus.BusObject - settingsObj dbus.BusObject - currentUID uint64 - subscribers syncmap.Map[string, chan FreedeskState] - screensaverSubscribers syncmap.Map[string, chan ScreensaverState] - screensaverCookieCounter uint32 + state *FreedeskState + stateMutex sync.RWMutex + systemConn *dbus.Conn + sessionConn *dbus.Conn + accountsObj dbus.BusObject + settingsObj dbus.BusObject + currentUID uint64 + subscribers syncmap.Map[string, chan FreedeskState] + screensaverSubscribers syncmap.Map[string, chan ScreensaverState] + screensaverCookieCounter uint32 + screensaverFreedesktopClaimed bool + screensaverGnomeClaimed bool } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index bec5084a..ae6d51cc 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -1516,7 +1516,11 @@ func Start(printDocs bool) error { } }() + loginctlReady := make(chan struct{}) + freedesktopReady := make(chan struct{}) + go func() { + defer close(loginctlReady) if err := InitializeLoginctlManager(); err != nil { log.Warnf("Loginctl manager unavailable: %v", err) } else { @@ -1525,6 +1529,7 @@ func Start(printDocs bool) error { }() go func() { + defer close(freedesktopReady) if err := InitializeFreedeskManager(); err != nil { log.Warnf("Freedesktop manager unavailable: %v", err) } else if freedesktopManager != nil { @@ -1533,6 +1538,31 @@ func Start(printDocs bool) error { } }() + // Bridge loginctl lock state to the freedesktop/gnome screensaver + // ActiveChanged signal so apps like Bitwarden can detect screen lock. + go func() { + <-loginctlReady + <-freedesktopReady + + if loginctlManager == nil || freedesktopManager == nil { + return + } + + ch := loginctlManager.Subscribe("dms-lock-bridge") + defer loginctlManager.Unsubscribe("dms-lock-bridge") + + initial := loginctlManager.GetState() + lastLocked := initial.Locked + freedesktopManager.SetScreenLockActive(lastLocked) + + for state := range ch { + if state.Locked != lastLocked { + lastLocked = state.Locked + freedesktopManager.SetScreenLockActive(lastLocked) + } + } + }() + if err := InitializeWaylandManager(); err != nil { log.Warnf("Wayland manager unavailable: %v", err) }