summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go88
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go10
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go156
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go500
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go723
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go296
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go155
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go298
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go76
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go50
10 files changed, 2352 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go
new file mode 100644
index 0000000..858248e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go
@@ -0,0 +1,88 @@
+package loginctl
+
+import (
+ "fmt"
+)
+
+func (m *Manager) Lock() error {
+ if m.sessionObj == nil {
+ return fmt.Errorf("session object not available")
+ }
+ err := m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to lock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Unlock() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to unlock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Activate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to activate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetIdleHint(idle bool) error {
+ err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to set idle hint: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Terminate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to terminate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetLockBeforeSuspend(enabled bool) {
+ m.lockBeforeSuspend.Store(enabled)
+}
+
+func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
+ m.sleepInhibitorEnabled.Store(enabled)
+ if enabled {
+ // Re-acquire inhibitor if enabled
+ m.acquireSleepInhibitor()
+ } else {
+ // Release inhibitor if disabled
+ m.releaseSleepInhibitor()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go
new file mode 100644
index 0000000..1a29588
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go
@@ -0,0 +1,10 @@
+package loginctl
+
+const (
+ dbusDest = "org.freedesktop.login1"
+ dbusPath = "/org/freedesktop/login1"
+ dbusManagerInterface = "org.freedesktop.login1.Manager"
+ dbusSessionInterface = "org.freedesktop.login1.Session"
+ dbusUserInterface = "org.freedesktop.login1.User"
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+)
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go
new file mode 100644
index 0000000..564e996
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go
@@ -0,0 +1,156 @@
+package loginctl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "loginctl.getState":
+ handleGetState(conn, req, manager)
+ case "loginctl.lock":
+ handleLock(conn, req, manager)
+ case "loginctl.unlock":
+ handleUnlock(conn, req, manager)
+ case "loginctl.activate":
+ handleActivate(conn, req, manager)
+ case "loginctl.setIdleHint":
+ handleSetIdleHint(conn, req, manager)
+ case "loginctl.setLockBeforeSuspend":
+ handleSetLockBeforeSuspend(conn, req, manager)
+ case "loginctl.setSleepInhibitorEnabled":
+ handleSetSleepInhibitorEnabled(conn, req, manager)
+ case "loginctl.lockerReady":
+ handleLockerReady(conn, req, manager)
+ case "loginctl.terminate":
+ handleTerminate(conn, req, manager)
+ case "loginctl.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleLock(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Lock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked"})
+}
+
+func handleUnlock(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Unlock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "unlocked"})
+}
+
+func handleActivate(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Activate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "activated"})
+}
+
+func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) {
+ idle, err := params.Bool(req.Params, "idle")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetIdleHint(idle); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"})
+}
+
+func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetLockBeforeSuspend(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
+}
+
+func handleSetSleepInhibitorEnabled(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetSleepInhibitorEnabled(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
+}
+
+func handleLockerReady(conn net.Conn, req models.Request, manager *Manager) {
+ manager.lockTimerMu.Lock()
+ if manager.lockTimer != nil {
+ manager.lockTimer.Stop()
+ manager.lockTimer = nil
+ }
+ manager.lockTimerMu.Unlock()
+
+ id := manager.sleepCycleID.Load()
+ manager.releaseForCycle(id)
+
+ if manager.inSleepCycle.Load() {
+ manager.signalLockerReady()
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "ok"})
+}
+
+func handleTerminate(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.Terminate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "terminated"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: initialState,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go
new file mode 100644
index 0000000..d3169e4
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go
@@ -0,0 +1,500 @@
+package loginctl
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestRespondError_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ result := models.SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, "1", resp.Result.SessionID)
+ assert.False(t, resp.Result.Locked)
+ assert.True(t, resp.Result.Active)
+}
+
+func TestHandleLock(t *testing.T) {
+ t.Run("successful lock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "locked", resp.Result.Message)
+ })
+
+ t.Run("lock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to lock session")
+ })
+}
+
+func TestHandleUnlock(t *testing.T) {
+ t.Run("successful unlock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "unlocked", resp.Result.Message)
+ })
+
+ t.Run("unlock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to unlock session")
+ })
+}
+
+func TestHandleActivate(t *testing.T) {
+ t.Run("successful activate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "activated", resp.Result.Message)
+ })
+
+ t.Run("activate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to activate session")
+ })
+}
+
+func TestHandleSetIdleHint(t *testing.T) {
+ t.Run("missing idle parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{},
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'idle' parameter")
+ })
+
+ t.Run("successful set idle hint true", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), true).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{
+ "idle": true,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "idle hint set", resp.Result.Message)
+ })
+
+ t.Run("set idle hint fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), false).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]any{
+ "idle": false,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to set idle hint")
+ })
+}
+
+func TestHandleTerminate(t *testing.T) {
+ t.Run("successful terminate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "terminated", resp.Result.Message)
+ })
+
+ t.Run("terminate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[models.SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to terminate session")
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+
+ t.Run("lock method", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", mock.Anything).Return(mockCall)
+
+ manager.sessionObj = mockSessionObj
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "loginctl.lock",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ })
+}
+
+func TestHandleSubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{ID: 123, Method: "loginctl.subscribe"}
+
+ done := make(chan bool)
+ go func() {
+ handleSubscribe(conn, req, manager)
+ done <- true
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ conn.Close()
+
+ if conn.writeBuf.Len() > 0 {
+ var resp models.Response[SessionEvent]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ if err == nil {
+ assert.Equal(t, 123, resp.ID)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, EventStateChanged, resp.Result.Type)
+ assert.Equal(t, "1", resp.Result.Data.SessionID)
+ }
+ }
+
+ select {
+ case <-done:
+ case <-time.After(100 * time.Millisecond):
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go
new file mode 100644
index 0000000..0c51e64
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go
@@ -0,0 +1,723 @@
+package loginctl
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ m := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+
+ stopChan: make(chan struct{}),
+ conn: conn,
+ dirty: make(chan struct{}, 1),
+ signals: make(chan *dbus.Signal, 256),
+ }
+ m.sleepInhibitorEnabled.Store(true)
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.acquireSleepInhibitor(); err != nil {
+ fmt.Fprintf(os.Stderr, "sleep inhibitor unavailable: %v\n", err)
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ return m, nil
+}
+
+func (m *Manager) initialize() error {
+ m.managerObj = m.conn.Object(dbusDest, dbus.ObjectPath(dbusPath))
+
+ m.initializeFallbackDelay()
+
+ sessionID, sessionPath, err := m.discoverSession()
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.Lock()
+ m.state.SessionID = sessionID
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) {
+ // 1. Explicit XDG_SESSION_ID
+ if id := os.Getenv("XDG_SESSION_ID"); id != "" {
+ if path, err := m.getSession(id); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id)
+ return id, path, nil
+ }
+ }
+
+ // 2. PID-based lookup (works when caller is inside a session cgroup)
+ if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id)
+ return id, path, nil
+ }
+
+ // 3. User's primary display session (handles UWSM and similar)
+ if id, path, err := m.getUserDisplaySession(); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id)
+ return id, path, nil
+ }
+
+ // 4. Score all sessions for current UID
+ if id, path, err := m.findBestSession(); err == nil {
+ fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id)
+ return id, path, nil
+ }
+
+ // 5. Last resort: "self"
+ path, err := m.getSession("self")
+ if err != nil {
+ return "", "", fmt.Errorf("%w", err)
+ }
+ return "self", path, nil
+}
+
+func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
+ var out dbus.ObjectPath
+ err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
+ if err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) {
+ var path dbus.ObjectPath
+ if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil {
+ return "", "", err
+ }
+
+ sessionObj := m.conn.Object(dbusDest, path)
+ var id dbus.Variant
+ if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil {
+ return "", "", err
+ }
+ return id.Value().(string), path, nil
+}
+
+func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) {
+ uid := uint32(os.Getuid())
+
+ var userPath dbus.ObjectPath
+ if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil {
+ return "", "", err
+ }
+
+ userObj := m.conn.Object(dbusDest, userPath)
+ var display dbus.Variant
+ if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil {
+ return "", "", err
+ }
+
+ pair, ok := display.Value().([]any)
+ if !ok || len(pair) < 2 {
+ return "", "", fmt.Errorf("unexpected Display format")
+ }
+
+ sessionID, _ := pair[0].(string)
+ sessionPath, _ := pair[1].(dbus.ObjectPath)
+ if sessionID == "" || sessionPath == "" {
+ return "", "", fmt.Errorf("empty Display session")
+ }
+
+ return sessionID, sessionPath, nil
+}
+
+type sessionCandidate struct {
+ id string
+ path dbus.ObjectPath
+}
+
+func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) {
+ // ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path]
+ var raw [][]any
+ if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil {
+ return "", "", err
+ }
+
+ uid := uint32(os.Getuid())
+ var candidates []sessionCandidate
+ for _, entry := range raw {
+ if len(entry) < 5 {
+ continue
+ }
+ entryUID, _ := entry[1].(uint32)
+ if entryUID != uid {
+ continue
+ }
+ id, _ := entry[0].(string)
+ path, _ := entry[4].(dbus.ObjectPath)
+ if id != "" && path != "" {
+ candidates = append(candidates, sessionCandidate{id: id, path: path})
+ }
+ }
+ if len(candidates) == 0 {
+ return "", "", fmt.Errorf("no sessions for uid %d", uid)
+ }
+
+ bestScore := -1
+ var best sessionCandidate
+ for _, c := range candidates {
+ score := m.scoreSession(c.path)
+ if score > bestScore {
+ bestScore = score
+ best = c
+ }
+ }
+ if bestScore < 0 {
+ return "", "", fmt.Errorf("no viable session found")
+ }
+ return best.id, best.path, nil
+}
+
+func (m *Manager) scoreSession(path dbus.ObjectPath) int {
+ obj := m.conn.Object(dbusDest, path)
+ var props map[string]dbus.Variant
+ if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil {
+ return -1
+ }
+
+ getStr := func(key string) string {
+ if v, ok := props[key]; ok {
+ if s, ok := v.Value().(string); ok {
+ return s
+ }
+ }
+ return ""
+ }
+ getBool := func(key string) bool {
+ if v, ok := props[key]; ok {
+ if b, ok := v.Value().(bool); ok {
+ return b
+ }
+ }
+ return false
+ }
+ getUint32 := func(key string) uint32 {
+ if v, ok := props[key]; ok {
+ if u, ok := v.Value().(uint32); ok {
+ return u
+ }
+ }
+ return 0
+ }
+
+ class := getStr("Class")
+ if class != "user" {
+ return -1
+ }
+ if getBool("Remote") {
+ return -1
+ }
+
+ score := 0
+
+ if getBool("Active") {
+ score += 100
+ }
+
+ switch getStr("Type") {
+ case "wayland", "x11":
+ score += 80
+ case "tty":
+ score += 10
+ }
+
+ if v, ok := props["Seat"]; ok {
+ if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
+ if seat, ok := seatArr[0].(string); ok && seat != "" {
+ score += 40
+ if seat == "seat0" {
+ score += 10
+ }
+ }
+ }
+ }
+
+ if getUint32("VTNr") > 0 {
+ score += 20
+ }
+
+ return score
+}
+
+func (m *Manager) refreshSessionBinding() error {
+ if m.managerObj == nil || m.conn == nil {
+ return fmt.Errorf("manager not fully initialized")
+ }
+
+ sessionPath, err := m.getSession(m.state.SessionID)
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.RLock()
+ currentPath := m.sessionPath
+ m.stateMutex.RUnlock()
+
+ if sessionPath == currentPath {
+ return nil
+ }
+
+ m.stopSignalPump()
+
+ m.stateMutex.Lock()
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ m.signals = make(chan *dbus.Signal, 256)
+ return m.startSignalPump()
+}
+
+func (m *Manager) updateSessionState() error {
+ ctx := context.Background()
+ props, err := m.getSessionProperties(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active)
+ m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint)
+ m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint)
+ if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok {
+ m.state.LockedHint = lockedHint
+ m.state.Locked = lockedHint
+ }
+ m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType)
+ m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass)
+ if v, ok := props["User"]; ok {
+ if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 {
+ if uid, ok := userArr[0].(uint32); ok {
+ m.state.User = uid
+ }
+ }
+ }
+ m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName)
+ m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost)
+ m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service)
+ m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY)
+ m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display)
+ m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote)
+ if v, ok := props["Seat"]; ok {
+ if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
+ if seatID, ok := seatArr[0].(string); ok {
+ m.state.Seat = seatID
+ }
+ }
+ }
+ m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr)
+
+ return nil
+}
+
+func (m *Manager) getSessionProperties(ctx context.Context) (map[string]dbus.Variant, error) {
+ var props map[string]dbus.Variant
+ err := m.sessionObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props)
+ if err != nil {
+ return nil, err
+ }
+ return props, nil
+}
+
+func (m *Manager) acquireSleepInhibitor() error {
+ if !m.sleepInhibitorEnabled.Load() {
+ return nil
+ }
+
+ m.inhibitMu.Lock()
+ defer m.inhibitMu.Unlock()
+
+ if m.inhibitFile != nil {
+ return nil
+ }
+
+ if m.managerObj == nil {
+ return fmt.Errorf("manager object not available")
+ }
+
+ file, err := m.inhibit("sleep", "DankMaterialShell", "Lock before suspend", "delay")
+ if err != nil {
+ return err
+ }
+
+ m.inhibitFile = file
+ return nil
+}
+
+func (m *Manager) inhibit(what, who, why, mode string) (*os.File, error) {
+ var fd dbus.UnixFD
+ err := m.managerObj.Call(dbusManagerInterface+".Inhibit", 0, what, who, why, mode).Store(&fd)
+ if err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), "inhibit"), nil
+}
+
+func (m *Manager) releaseSleepInhibitor() {
+ m.inhibitMu.Lock()
+ f := m.inhibitFile
+ m.inhibitFile = nil
+ m.inhibitMu.Unlock()
+ if f != nil {
+ f.Close()
+ }
+}
+
+func (m *Manager) releaseForCycle(id uint64) {
+ if !m.inSleepCycle.Load() || m.sleepCycleID.Load() != id {
+ return
+ }
+ m.releaseSleepInhibitor()
+}
+
+func (m *Manager) initializeFallbackDelay() {
+ var maxDelayUSec uint64
+ err := m.managerObj.Call(
+ dbusPropsInterface+".Get",
+ 0,
+ dbusManagerInterface,
+ "InhibitDelayMaxUSec",
+ ).Store(&maxDelayUSec)
+
+ if err != nil {
+ m.fallbackDelay = 2 * time.Second
+ return
+ }
+
+ maxDelay := time.Duration(maxDelayUSec) * time.Microsecond
+ computed := (maxDelay * 8) / 10
+
+ if computed < 2*time.Second {
+ m.fallbackDelay = 2 * time.Second
+ } else if computed > 4*time.Second {
+ m.fallbackDelay = 4 * time.Second
+ } else {
+ m.fallbackDelay = computed
+ }
+}
+
+func (m *Manager) newLockerReadyCh() chan struct{} {
+ m.lockerReadyChMu.Lock()
+ defer m.lockerReadyChMu.Unlock()
+ m.lockerReadyCh = make(chan struct{})
+ return m.lockerReadyCh
+}
+
+func (m *Manager) signalLockerReady() {
+ m.lockerReadyChMu.Lock()
+ ch := m.lockerReadyCh
+ if ch != nil {
+ close(ch)
+ m.lockerReadyCh = nil
+ }
+ m.lockerReadyChMu.Unlock()
+}
+
+func (m *Manager) snapshotState() SessionState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return *m.state
+}
+
+func stateChangedMeaningfully(old, new *SessionState) bool {
+ if old.Locked != new.Locked {
+ return true
+ }
+ if old.LockedHint != new.LockedHint {
+ return true
+ }
+ if old.Active != new.Active {
+ return true
+ }
+ if old.IdleHint != new.IdleHint {
+ return true
+ }
+ if old.PreparingForSleep != new.PreparingForSleep {
+ return true
+ }
+ return false
+}
+
+func (m *Manager) GetState() SessionState {
+ return m.snapshotState()
+}
+
+func (m *Manager) Subscribe(id string) chan SessionState {
+ ch := make(chan SessionState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan SessionState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) startSignalPump() error {
+ m.conn.Signal(m.signals)
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleDBusSignal(sig)
+ }
+ }
+ }()
+ return nil
+}
+
+func (m *Manager) stopSignalPump() {
+ if m.conn == nil {
+ return
+ }
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ )
+
+ m.conn.RemoveSignal(m.signals)
+ close(m.signals)
+
+ m.sigWG.Wait()
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ m.stopSignalPump()
+
+ m.releaseSleepInhibitor()
+
+ m.subscribers.Range(func(key string, ch chan SessionState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ if m.conn != nil {
+ m.conn.Close()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go
new file mode 100644
index 0000000..148ee60
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go
@@ -0,0 +1,296 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ IdleHint: false,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.Equal(t, "1", result.SessionID)
+ assert.False(t, result.Locked)
+ assert.True(t, result.Active)
+ assert.Equal(t, "wayland", result.SessionType)
+ assert.Equal(t, "testuser", result.UserName)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 64, cap(ch))
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.True(t, exists)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ ch := manager.Subscribe("test-client")
+
+ manager.Unsubscribe("test-client")
+
+ _, ok := <-ch
+ assert.False(t, ok)
+
+ _, exists := manager.subscribers.Load("test-client")
+ assert.False(t, exists)
+}
+
+func TestManager_Unsubscribe_NonExistent(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ }
+
+ // Unsubscribe a non-existent client should not panic
+ assert.NotPanics(t, func() {
+ manager.Unsubscribe("non-existent")
+ })
+}
+
+func TestManager_NotifySubscribers(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+
+ select {
+ case state := <-ch:
+ assert.Equal(t, "1", state.SessionID)
+ assert.False(t, state.Locked)
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("did not receive state update")
+ }
+
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+}
+
+func TestManager_NotifySubscribers_Debounce(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subscribers.Store("test-client", ch)
+
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+
+ receivedCount := 0
+ timeout := time.After(200 * time.Millisecond)
+ for {
+ select {
+ case <-ch:
+ receivedCount++
+ case <-timeout:
+ assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update")
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+ return
+ }
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ }
+
+ ch1 := make(chan SessionState, 1)
+ ch2 := make(chan SessionState, 1)
+ manager.subscribers.Store("client1", ch1)
+ manager.subscribers.Store("client2", ch2)
+
+ manager.Close()
+
+ select {
+ case <-manager.stopChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("stopChan not closed")
+ }
+
+ _, ok1 := <-ch1
+ _, ok2 := <-ch2
+ assert.False(t, ok1, "ch1 should be closed")
+ assert.False(t, ok2, "ch2 should be closed")
+
+ count := 0
+ manager.subscribers.Range(func(key string, ch chan SessionState) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ select {
+ case <-done:
+ case <-time.After(1 * time.Second):
+ t.Fatal("timeout waiting for goroutines")
+ }
+ }
+}
+
+func TestStateChangedMeaningfully(t *testing.T) {
+ tests := []struct {
+ name string
+ old *SessionState
+ new *SessionState
+ expected bool
+ }{
+ {
+ name: "no change",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: false},
+ expected: false,
+ },
+ {
+ name: "locked changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: true, Active: true, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "active changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: false, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "idle hint changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: true},
+ expected: true,
+ },
+ {
+ name: "locked hint changed",
+ old: &SessionState{Locked: false, Active: true, LockedHint: false},
+ new: &SessionState{Locked: false, Active: true, LockedHint: true},
+ expected: true,
+ },
+ {
+ name: "preparing for sleep changed",
+ old: &SessionState{Locked: false, Active: true, PreparingForSleep: false},
+ new: &SessionState{Locked: false, Active: true, PreparingForSleep: true},
+ expected: true,
+ },
+ {
+ name: "non-meaningful change (username)",
+ old: &SessionState{Locked: false, Active: true, UserName: "user1"},
+ new: &SessionState{Locked: false, Active: true, UserName: "user2"},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := stateChangedMeaningfully(tt.old, tt.new)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestManager_SnapshotState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ snapshot := manager.snapshotState()
+ assert.Equal(t, "1", snapshot.SessionID)
+ assert.False(t, snapshot.Locked)
+ assert.True(t, snapshot.Active)
+ assert.Equal(t, "testuser", snapshot.UserName)
+
+ snapshot.Locked = true
+ assert.False(t, manager.state.Locked)
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, &manager.subscribers)
+ assert.NotNil(t, manager.stopChan)
+
+ manager.Close()
+ }
+ })
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go
new file mode 100644
index 0000000..4c0605a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go
@@ -0,0 +1,155 @@
+package loginctl
+
+import (
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case dbusSessionInterface + ".Lock":
+ m.stateMutex.Lock()
+ m.state.Locked = true
+ m.state.LockedHint = true
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ if m.sleepInhibitorEnabled.Load() && m.inSleepCycle.Load() {
+ id := m.sleepCycleID.Load()
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ }
+ m.lockTimer = time.AfterFunc(m.fallbackDelay, func() {
+ m.releaseForCycle(id)
+ })
+ m.lockTimerMu.Unlock()
+ }
+
+ case dbusSessionInterface + ".Unlock":
+ m.stateMutex.Lock()
+ m.state.Locked = false
+ m.state.LockedHint = false
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ // Cancel the lock timer if it's still running
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ m.lockTimer = nil
+ }
+ m.lockTimerMu.Unlock()
+
+ // Re-acquire the sleep inhibitor (acquireSleepInhibitor checks the enabled flag)
+ m.acquireSleepInhibitor()
+
+ case dbusManagerInterface + ".PrepareForSleep":
+ if len(sig.Body) == 0 {
+ return
+ }
+ preparing, _ := sig.Body[0].(bool)
+
+ if preparing {
+ cycleID := m.sleepCycleID.Add(1)
+ m.inSleepCycle.Store(true)
+
+ if m.lockBeforeSuspend.Load() {
+ m.Lock()
+ }
+
+ readyCh := m.newLockerReadyCh()
+ go func(id uint64, ch <-chan struct{}) {
+ <-ch
+ if m.inSleepCycle.Load() && m.sleepCycleID.Load() == id {
+ m.releaseSleepInhibitor()
+ }
+ }(cycleID, readyCh)
+ } else {
+ m.inSleepCycle.Store(false)
+ m.signalLockerReady()
+ m.refreshSessionBinding()
+ m.acquireSleepInhibitor()
+ }
+
+ m.stateMutex.Lock()
+ m.state.PreparingForSleep = preparing
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ case dbusPropsInterface + ".PropertiesChanged":
+ m.handlePropertiesChanged(sig)
+
+ case "org.freedesktop.DBus.NameOwnerChanged":
+ if len(sig.Body) == 3 {
+ name, _ := sig.Body[0].(string)
+ oldOwner, _ := sig.Body[1].(string)
+ newOwner, _ := sig.Body[2].(string)
+ if name == dbusDest && oldOwner != "" && newOwner != "" {
+ m.updateSessionState()
+ if !m.inSleepCycle.Load() {
+ m.acquireSleepInhibitor()
+ }
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok || iface != dbusSessionInterface {
+ return
+ }
+
+ changes, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ var needsUpdate bool
+
+ for key, variant := range changes {
+ switch key {
+ case "Active":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.Active = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "IdleHint":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.IdleHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "IdleSinceHint":
+ if val, ok := dbusutil.As[uint64](variant); ok {
+ m.stateMutex.Lock()
+ m.state.IdleSinceHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ case "LockedHint":
+ if val, ok := dbusutil.As[bool](variant); ok {
+ m.stateMutex.Lock()
+ m.state.LockedHint = val
+ m.state.Locked = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ }
+ }
+
+ if needsUpdate {
+ m.notifySubscribers()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go
new file mode 100644
index 0000000..ecbb57f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go
@@ -0,0 +1,298 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_HandleDBusSignal_Lock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: false,
+ LockedHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Lock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Locked)
+ assert.True(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: true,
+ LockedHint: true,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Unlock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Locked)
+ assert.False(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
+ t.Run("preparing for sleep - true", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{true},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("preparing for sleep - false", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: true,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{false},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []any{},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+}
+
+func TestManager_HandlePropertiesChanged(t *testing.T) {
+ t.Run("active property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ })
+
+ t.Run("idle hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.IdleHint)
+ })
+
+ t.Run("idle since hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleSinceHint: 0,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleSinceHint": dbus.MakeVariant(uint64(123456789)),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.Equal(t, uint64(123456789), manager.state.IdleSinceHint)
+ })
+
+ t.Run("locked hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ LockedHint: false,
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "LockedHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.LockedHint)
+ assert.True(t, manager.state.Locked)
+ })
+
+ t.Run("wrong interface", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.SomeOtherInterface",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Active)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{},
+ }
+
+ assert.NotPanics(t, func() {
+ manager.handlePropertiesChanged(sig)
+ })
+ })
+
+ t.Run("multiple properties changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []any{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ assert.True(t, manager.state.IdleHint)
+ })
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go
new file mode 100644
index 0000000..76c7414
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go
@@ -0,0 +1,76 @@
+package loginctl
+
+import (
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type SessionState struct {
+ SessionID string `json:"sessionId"`
+ SessionPath string `json:"sessionPath"`
+ Locked bool `json:"locked"`
+ Active bool `json:"active"`
+ IdleHint bool `json:"idleHint"`
+ IdleSinceHint uint64 `json:"idleSinceHint"`
+ LockedHint bool `json:"lockedHint"`
+ SessionType string `json:"sessionType"`
+ SessionClass string `json:"sessionClass"`
+ User uint32 `json:"user"`
+ UserName string `json:"userName"`
+ RemoteHost string `json:"remoteHost"`
+ Service string `json:"service"`
+ TTY string `json:"tty"`
+ Display string `json:"display"`
+ Remote bool `json:"remote"`
+ Seat string `json:"seat"`
+ VTNr uint32 `json:"vtnr"`
+ PreparingForSleep bool `json:"preparingForSleep"`
+}
+
+type EventType string
+
+const (
+ EventStateChanged EventType = "state_changed"
+ EventLock EventType = "lock"
+ EventUnlock EventType = "unlock"
+ EventPrepareForSleep EventType = "prepare_for_sleep"
+ EventIdleHintChanged EventType = "idle_hint_changed"
+ EventLockedHintChanged EventType = "locked_hint_changed"
+)
+
+type SessionEvent struct {
+ Type EventType `json:"type"`
+ Data SessionState `json:"data"`
+}
+
+type Manager struct {
+ state *SessionState
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan SessionState]
+ stopChan chan struct{}
+ conn *dbus.Conn
+ sessionPath dbus.ObjectPath
+ managerObj dbus.BusObject
+ sessionObj dbus.BusObject
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *SessionState
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ inhibitMu sync.Mutex
+ inhibitFile *os.File
+ lockBeforeSuspend atomic.Bool
+ inSleepCycle atomic.Bool
+ sleepCycleID atomic.Uint64
+ lockerReadyChMu sync.Mutex
+ lockerReadyCh chan struct{}
+ lockTimerMu sync.Mutex
+ lockTimer *time.Timer
+ sleepInhibitorEnabled atomic.Bool
+ fallbackDelay time.Duration
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go
new file mode 100644
index 0000000..383300f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go
@@ -0,0 +1,50 @@
+package loginctl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEventType_Constants(t *testing.T) {
+ assert.Equal(t, EventType("state_changed"), EventStateChanged)
+ assert.Equal(t, EventType("lock"), EventLock)
+ assert.Equal(t, EventType("unlock"), EventUnlock)
+ assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
+ assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
+ assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
+}
+
+func TestSessionState_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ SessionType: "wayland",
+ User: 1000,
+ UserName: "testuser",
+ }
+
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ assert.False(t, state.Locked)
+ assert.Equal(t, "wayland", state.SessionType)
+ assert.Equal(t, uint32(1000), state.User)
+ assert.Equal(t, "testuser", state.UserName)
+}
+
+func TestSessionEvent_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ Locked: true,
+ }
+
+ event := SessionEvent{
+ Type: EventLock,
+ Data: state,
+ }
+
+ assert.Equal(t, EventLock, event.Type)
+ assert.Equal(t, "1", event.Data.SessionID)
+ assert.True(t, event.Data.Locked)
+}