diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl')
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, ¤tState) { + 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) +} |