diff options
| author | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
|---|---|---|
| committer | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
| commit | 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch) | |
| tree | ab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop | |
| parent | 5d94c0a7d44a2255b81815a52a7056a94a39842d (diff) | |
| download | RaveOS-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/freedesktop')
11 files changed, 1951 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go new file mode 100644 index 0000000..52515d1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go @@ -0,0 +1,112 @@ +package freedesktop + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/godbus/dbus/v5" +) + +func (m *Manager) SetIconFile(iconPath string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetIconFile", 0, iconPath).Err + if err != nil { + return fmt.Errorf("failed to set icon file: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetRealName(name string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetRealName", 0, name).Err + if err != nil { + return fmt.Errorf("failed to set real name: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetEmail(email string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetEmail", 0, email).Err + if err != nil { + return fmt.Errorf("failed to set email: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetLanguage(language string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLanguage", 0, language).Err + if err != nil { + return fmt.Errorf("failed to set language: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetLocation(location string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLocation", 0, location).Err + if err != nil { + return fmt.Errorf("failed to set location: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) GetUserIconFile(username string) (string, error) { + if m.systemConn == nil { + return "", fmt.Errorf("accounts service not available") + } + + accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath)) + + var userPath dbus.ObjectPath + err := accountsManager.Call(dbusAccountsInterface+".FindUserByName", 0, username).Store(&userPath) + if err != nil { + return "", fmt.Errorf("user not found: %w", err) + } + + userObj := m.systemConn.Object(dbusAccountsDest, userPath) + variant, err := userObj.GetProperty(dbusAccountsUserInterface + ".IconFile") + if err != nil { + return "", err + } + + var iconFile string + if err := variant.Store(&iconFile); err != nil { + return "", err + } + + return iconFile, nil +} + +func (m *Manager) SetIconTheme(iconTheme string) error { + if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil { + return fmt.Errorf("failed to set icon theme: %w", err) + } + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go new file mode 100644 index 0000000..e2c21e8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go @@ -0,0 +1,145 @@ +package freedesktop + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_SetIconFile(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetIconFile("/path/to/icon.png") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetRealName(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetRealName("New Name") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetEmail(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetEmail("test@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetLanguage(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetLanguage("en_US.UTF-8") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetLocation(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetLocation("Test Location") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_GetUserIconFile(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + iconFile, err := manager.GetUserIconFile("testuser") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + assert.Empty(t, iconFile) + }) +} + +func TestManager_UpdateAccountsState(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.updateAccountsState() + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_UpdateSettingsState(t *testing.T) { + t.Run("settings not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.updateSettingsState() + assert.Error(t, err) + assert.Contains(t, err.Error(), "settings portal not available") + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go new file mode 100644 index 0000000..12db87b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go @@ -0,0 +1,23 @@ +package freedesktop + +const ( + dbusAccountsDest = "org.freedesktop.Accounts" + dbusAccountsPath = "/org/freedesktop/Accounts" + dbusAccountsInterface = "org.freedesktop.Accounts" + dbusAccountsUserInterface = "org.freedesktop.Accounts.User" + + dbusPortalDest = "org.freedesktop.portal.Desktop" + dbusPortalPath = "/org/freedesktop/portal/desktop" + dbusPortalSettingsInterface = "org.freedesktop.portal.Settings" + + dbusPropsInterface = "org.freedesktop.DBus.Properties" + + dbusScreensaverName = "org.freedesktop.ScreenSaver" + dbusScreensaverPath = "/ScreenSaver" + dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver" + dbusScreensaverInterface = "org.freedesktop.ScreenSaver" + + dbusGnomeScreensaverName = "org.gnome.ScreenSaver" + dbusGnomeScreensaverPath = "/org/gnome/ScreenSaver" + dbusGnomeScreensaverInterface = "org.gnome.ScreenSaver" +) diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go new file mode 100644 index 0000000..c3090fa --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go @@ -0,0 +1,154 @@ +package freedesktop + +import ( + "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 "freedesktop.getState": + handleGetState(conn, req, manager) + case "freedesktop.accounts.setIconFile": + handleSetIconFile(conn, req, manager) + case "freedesktop.accounts.setRealName": + handleSetRealName(conn, req, manager) + case "freedesktop.accounts.setEmail": + handleSetEmail(conn, req, manager) + case "freedesktop.accounts.setLanguage": + handleSetLanguage(conn, req, manager) + case "freedesktop.accounts.setLocation": + handleSetLocation(conn, req, manager) + case "freedesktop.accounts.getUserIconFile": + handleGetUserIconFile(conn, req, manager) + case "freedesktop.settings.getColorScheme": + handleGetColorScheme(conn, req, manager) + case "freedesktop.settings.setIconTheme": + handleSetIconTheme(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 handleSetIconFile(conn net.Conn, req models.Request, manager *Manager) { + iconPath, err := params.String(req.Params, "path") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetIconFile(iconPath); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon file set"}) +} + +func handleSetRealName(conn net.Conn, req models.Request, manager *Manager) { + name, err := params.String(req.Params, "name") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetRealName(name); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "real name set"}) +} + +func handleSetEmail(conn net.Conn, req models.Request, manager *Manager) { + email, err := params.String(req.Params, "email") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetEmail(email); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "email set"}) +} + +func handleSetLanguage(conn net.Conn, req models.Request, manager *Manager) { + language, err := params.String(req.Params, "language") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLanguage(language); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "language set"}) +} + +func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) { + location, err := params.String(req.Params, "location") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLocation(location); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"}) +} + +func handleGetUserIconFile(conn net.Conn, req models.Request, manager *Manager) { + username, err := params.String(req.Params, "username") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + iconFile, err := manager.GetUserIconFile(username) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Value: iconFile}) +} + +func handleGetColorScheme(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.updateSettingsState(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + state := manager.GetState() + models.Respond(conn, req.ID, map[string]uint32{"colorScheme": state.Settings.ColorScheme}) +} + +func handleSetIconTheme(conn net.Conn, req models.Request, manager *Manager) { + iconTheme, err := params.String(req.Params, "iconTheme") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetIconTheme(iconTheme); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon theme set"}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go new file mode 100644 index 0000000..f2cb5a2 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go @@ -0,0 +1,581 @@ +package freedesktop + +import ( + "bytes" + "encoding/json" + "net" + "sync" + "testing" + + 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 mockGetAllAccountsProperties() *dbus.Call { + props := map[string]dbus.Variant{ + "IconFile": dbus.MakeVariant("/path/to/icon.png"), + "RealName": dbus.MakeVariant("Test"), + "UserName": dbus.MakeVariant("test"), + "AccountType": dbus.MakeVariant(int32(0)), + "HomeDirectory": dbus.MakeVariant("/home/test"), + "Shell": dbus.MakeVariant("/bin/bash"), + "Email": dbus.MakeVariant(""), + "Language": dbus.MakeVariant(""), + "Location": dbus.MakeVariant(""), + "Locked": dbus.MakeVariant(false), + "PasswordMode": dbus.MakeVariant(int32(1)), + } + return &dbus.Call{Err: nil, Body: []any{props}} +} + +func TestRespondError_Freedesktop(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_Freedesktop(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: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + RealName: "Test User", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.getState"} + + handleGetState(conn, req, manager) + + var resp models.Response[FreedeskState] + 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.Accounts.Available) + assert.Equal(t, "testuser", resp.Result.Accounts.UserName) + assert.True(t, resp.Result.Settings.Available) + assert.Equal(t, uint32(1), resp.Result.Settings.ColorScheme) +} + +func TestHandleSetIconFile(t *testing.T) { + t.Run("missing path parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{}, + } + + handleSetIconFile(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 'path' parameter") + }) + + t.Run("successful set icon file", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetIconFile", dbus.Flags(0), "/path/to/icon.png").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{ + "path": "/path/to/icon.png", + }, + } + + handleSetIconFile(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, "icon file set", resp.Result.Message) + }) + + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{ + "path": "/path/to/icon.png", + }, + } + + handleSetIconFile(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, "accounts service not available") + }) +} + +func TestHandleSetRealName(t *testing.T) { + t.Run("missing name parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setRealName", + Params: map[string]any{}, + } + + handleSetRealName(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 'name' parameter") + }) + + t.Run("successful set real name", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetRealName", dbus.Flags(0), "New Name").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setRealName", + Params: map[string]any{ + "name": "New Name", + }, + } + + handleSetRealName(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, "real name set", resp.Result.Message) + }) +} + +func TestHandleSetEmail(t *testing.T) { + t.Run("missing email parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setEmail", + Params: map[string]any{}, + } + + handleSetEmail(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 'email' parameter") + }) + + t.Run("successful set email", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetEmail", dbus.Flags(0), "test@example.com").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setEmail", + Params: map[string]any{ + "email": "test@example.com", + }, + } + + handleSetEmail(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, "email set", resp.Result.Message) + }) +} + +func TestHandleSetLanguage(t *testing.T) { + t.Run("missing language parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setLanguage", + Params: map[string]any{}, + } + + handleSetLanguage(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 'language' parameter") + }) +} + +func TestHandleSetLocation(t *testing.T) { + t.Run("missing location parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setLocation", + Params: map[string]any{}, + } + + handleSetLocation(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 'location' parameter") + }) +} + +func TestHandleGetUserIconFile(t *testing.T) { + t.Run("missing username parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.getUserIconFile", + Params: map[string]any{}, + } + + handleGetUserIconFile(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 'username' parameter") + }) + + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.getUserIconFile", + Params: map[string]any{ + "username": "testuser", + }, + } + + handleGetUserIconFile(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, "accounts service not available") + }) +} + +func TestHandleGetColorScheme(t *testing.T) { + t.Run("settings not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"} + + handleGetColorScheme(conn, req, manager) + + var resp models.Response[map[string]uint32] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "settings portal not available") + }) + + t.Run("successful get color scheme", func(t *testing.T) { + mockSettingsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{ + Err: nil, + Body: []any{dbus.MakeVariant(uint32(1))}, + } + mockSettingsObj.EXPECT().Call("org.freedesktop.portal.Settings.ReadOne", dbus.Flags(0), "org.freedesktop.appearance", "color-scheme").Return(mockCall) + + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + settingsObj: mockSettingsObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"} + + handleGetColorScheme(conn, req, manager) + + var resp models.Response[map[string]uint32] + 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, uint32(1), (*resp.Result)["colorScheme"]) + }) +} + +func TestHandleRequest(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + }, + stateMutex: sync.RWMutex{}, + } + + t.Run("unknown method", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.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: "freedesktop.getState", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[FreedeskState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + }) + + t.Run("all method routes", func(t *testing.T) { + tests := []string{ + "freedesktop.accounts.setIconFile", + "freedesktop.accounts.setRealName", + "freedesktop.accounts.setEmail", + "freedesktop.accounts.setLanguage", + "freedesktop.accounts.setLocation", + "freedesktop.accounts.getUserIconFile", + "freedesktop.settings.getColorScheme", + } + + for _, method := range tests { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: method, + Params: map[string]any{}, + } + + 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) + // Will have errors due to missing params or service unavailable + // but the method routing should work + } + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go new file mode 100644 index 0000000..054f859 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go @@ -0,0 +1,206 @@ +package freedesktop + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func NewManager() (*Manager, error) { + systemConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + sessionConn, err := dbus.ConnectSessionBus() + if err != nil { + sessionConn = nil + } + + m := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{}, + Settings: SettingsState{}, + Screensaver: ScreensaverState{}, + }, + stateMutex: sync.RWMutex{}, + systemConn: systemConn, + sessionConn: sessionConn, + currentUID: uint64(os.Getuid()), + } + + m.initializeAccounts() + m.initializeSettings() + m.initializeScreensaver() + + return m, nil +} + +func (m *Manager) initializeAccounts() error { + accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath)) + + var userPath dbus.ObjectPath + err := accountsManager.Call(dbusAccountsInterface+".FindUserById", 0, int64(m.currentUID)).Store(&userPath) + if err != nil { + m.stateMutex.Lock() + m.state.Accounts.Available = false + m.stateMutex.Unlock() + return err + } + + m.accountsObj = m.systemConn.Object(dbusAccountsDest, userPath) + + m.stateMutex.Lock() + m.state.Accounts.Available = true + m.state.Accounts.UserPath = string(userPath) + m.state.Accounts.UID = m.currentUID + m.stateMutex.Unlock() + + if err := m.updateAccountsState(); err != nil { + return fmt.Errorf("failed to update accounts state: %w", err) + } + + return nil +} + +func (m *Manager) initializeSettings() error { + if m.sessionConn == nil { + m.stateMutex.Lock() + m.state.Settings.Available = false + m.stateMutex.Unlock() + return fmt.Errorf("no session bus connection") + } + + m.settingsObj = m.sessionConn.Object(dbusPortalDest, dbus.ObjectPath(dbusPortalPath)) + + var variant dbus.Variant + err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant) + if err != nil { + m.stateMutex.Lock() + m.state.Settings.Available = false + m.stateMutex.Unlock() + return err + } + + m.stateMutex.Lock() + m.state.Settings.Available = true + m.stateMutex.Unlock() + + if err := m.updateSettingsState(); err != nil { + return fmt.Errorf("failed to update settings state: %w", err) + } + + return nil +} + +func (m *Manager) updateAccountsState() error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + ctx := context.Background() + props, err := m.getAccountProperties(ctx) + if err != nil { + return err + } + + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "") + m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "") + m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "") + m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0)) + m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "") + m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "") + m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "") + m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "") + m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "") + m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false) + m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0)) + + return nil +} + +func (m *Manager) updateSettingsState() error { + if !m.state.Settings.Available || m.settingsObj == nil { + return fmt.Errorf("settings portal not available") + } + + var variant dbus.Variant + err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant) + if err != nil { + return err + } + + if colorScheme, ok := dbusutil.As[uint32](variant); ok { + m.stateMutex.Lock() + m.state.Settings.ColorScheme = colorScheme + m.stateMutex.Unlock() + } + + return nil +} + +func (m *Manager) getAccountProperties(ctx context.Context) (map[string]dbus.Variant, error) { + var props map[string]dbus.Variant + err := m.accountsObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusAccountsUserInterface).Store(&props) + if err != nil { + return nil, err + } + return props, nil +} + +func (m *Manager) GetState() FreedeskState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return *m.state +} + +func (m *Manager) Subscribe(id string) chan FreedeskState { + ch := make(chan FreedeskState, 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) NotifySubscribers() { + state := m.GetState() + m.subscribers.Range(func(key string, ch chan FreedeskState) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.subscribers.Range(func(key string, ch chan FreedeskState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool { + close(ch) + m.screensaverSubscribers.Delete(key) + return true + }) + + if m.systemConn != nil { + m.systemConn.Close() + } + if m.sessionConn != nil { + m.sessionConn.Close() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go new file mode 100644 index 0000000..b3617f5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go @@ -0,0 +1,143 @@ +package freedesktop + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_GetState(t *testing.T) { + state := &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + RealName: "Test User", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + } + + manager := &Manager{ + state: state, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.True(t, result.Accounts.Available) + assert.Equal(t, "testuser", result.Accounts.UserName) + assert.Equal(t, "Test User", result.Accounts.RealName) + assert.Equal(t, uint64(1000), result.Accounts.UID) + assert.True(t, result.Settings.Available) + assert.Equal(t, uint32(1), result.Settings.ColorScheme) +} + +func TestManager_GetState_ThreadSafe(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + }, + stateMutex: sync.RWMutex{}, + } + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + state := manager.GetState() + assert.True(t, state.Accounts.Available) + assert.Equal(t, "testuser", state.Accounts.UserName) + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } +} + +func TestManager_Close(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + systemConn: nil, + sessionConn: nil, + } + + assert.NotPanics(t, func() { + manager.Close() + }) +} + +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.systemConn) + + manager.Close() + } + }) +} + +func TestManager_GetState_EmptyState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.False(t, result.Accounts.Available) + assert.Empty(t, result.Accounts.UserName) + assert.False(t, result.Settings.Available) + assert.Equal(t, uint32(0), result.Settings.ColorScheme) +} + +func TestManager_AccountsState_Modification(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + }, + stateMutex: sync.RWMutex{}, + } + + state := manager.GetState() + state.Accounts.UserName = "modifieduser" + + original := manager.GetState() + assert.Equal(t, "testuser", original.Accounts.UserName) +} + +func TestManager_SettingsState_Modification(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: true, + ColorScheme: 0, + }, + }, + stateMutex: sync.RWMutex{}, + } + + state := manager.GetState() + state.Settings.ColorScheme = 1 + + original := manager.GetState() + assert.Equal(t, uint32(0), original.Settings.ColorScheme) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go new file mode 100644 index 0000000..095a4c0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go @@ -0,0 +1,349 @@ +package freedesktop + +import ( + "fmt" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +type screensaverHandler struct { + manager *Manager +} + +func screensaverIntrospectIface(ifaceName string) introspect.Interface { + return introspect.Interface{ + Name: ifaceName, + Methods: []introspect.Method{ + { + Name: "Inhibit", + Args: []introspect.Arg{ + {Name: "application_name", Type: "s", Direction: "in"}, + {Name: "reason_for_inhibit", Type: "s", Direction: "in"}, + {Name: "cookie", Type: "u", Direction: "out"}, + }, + }, + { + Name: "UnInhibit", + Args: []introspect.Arg{ + {Name: "cookie", Type: "u", Direction: "in"}, + }, + }, + { + Name: "GetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "out"}, + }, + }, + { + Name: "SetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "in"}, + }, + }, + { + Name: "Lock", + }, + }, + Signals: []introspect.Signal{ + { + Name: "ActiveChanged", + Args: []introspect.Arg{ + {Name: "new_value", Type: "b"}, + }, + }, + }, + } +} + +func (m *Manager) initializeScreensaver() error { + if m.sessionConn == nil { + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil + } + + handler := &screensaverHandler{manager: m} + + m.screensaverFreedesktopClaimed = m.claimScreensaverName(handler, + dbusScreensaverName, dbusScreensaverInterface, dbusScreensaverPath, dbusScreensaverPath2) + m.screensaverGnomeClaimed = m.claimScreensaverName(handler, + dbusGnomeScreensaverName, dbusGnomeScreensaverInterface, dbusGnomeScreensaverPath) + + if !m.screensaverFreedesktopClaimed && !m.screensaverGnomeClaimed { + log.Warn("No screensaver interface could be claimed") + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil + } + + go m.watchPeerDisconnects() + + m.stateMutex.Lock() + m.state.Screensaver.Available = true + m.state.Screensaver.Active = false + m.state.Screensaver.Inhibited = false + m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{} + m.stateMutex.Unlock() + + log.Info("Screensaver listener initialized") + return nil +} + +func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface string, paths ...dbus.ObjectPath) bool { + reply, err := m.sessionConn.RequestName(name, dbus.NameFlagDoNotQueue) + if err != nil { + log.Warnf("Failed to request screensaver name %s: %v", name, err) + return false + } + if reply != dbus.RequestNameReplyPrimaryOwner { + log.Warnf("Screensaver name %s already owned by another process", name) + return false + } + if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil { + log.Warnf("Failed to export screensaver on %s: %v", name, err) + return false + } + log.Infof("Claimed %s on session bus", name) + return true +} + +// exportScreensaverOnPaths exports the handler and introspection on the given +// paths under the specified interface name. +func (m *Manager) exportScreensaverOnPaths(handler *screensaverHandler, ifaceName string, paths ...dbus.ObjectPath) error { + iface := screensaverIntrospectIface(ifaceName) + for _, path := range paths { + if err := m.sessionConn.Export(handler, path, ifaceName); err != nil { + return fmt.Errorf("export handler on %s: %w", path, err) + } + node := &introspect.Node{ + Name: string(path), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + iface, + }, + } + if err := m.sessionConn.Export(introspect.NewIntrospectable(node), path, "org.freedesktop.DBus.Introspectable"); err != nil { + log.Warnf("Failed to export introspectable on %s: %v", path, err) + } + } + return nil +} + +func (h *screensaverHandler) Inhibit(sender dbus.Sender, appName, reason string) (uint32, *dbus.Error) { + if appName == "" { + return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"application name required"}) + } + + if reason == "" { + return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"reason required"}) + } + + if strings.Contains(strings.ToLower(reason), "audio") && !strings.Contains(strings.ToLower(reason), "video") { + log.Debugf("Ignoring audio-only inhibit from %s: %s", appName, reason) + return 0, nil + } + + if idx := strings.LastIndex(appName, "/"); idx != -1 && idx < len(appName)-1 { + appName = appName[idx+1:] + } + appName = filepath.Base(appName) + + cookie := atomic.AddUint32(&h.manager.screensaverCookieCounter, 1) + + inhibitor := ScreensaverInhibitor{ + Cookie: cookie, + AppName: appName, + Reason: reason, + Peer: string(sender), + StartTime: time.Now().Unix(), + } + + h.manager.stateMutex.Lock() + h.manager.state.Screensaver.Inhibitors = append(h.manager.state.Screensaver.Inhibitors, inhibitor) + h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0 + h.manager.stateMutex.Unlock() + + log.Infof("Screensaver inhibited by %s (%s): %s -> cookie %08X", appName, sender, reason, cookie) + + h.manager.NotifyScreensaverSubscribers() + + return cookie, nil +} + +func (h *screensaverHandler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error { + h.manager.stateMutex.Lock() + defer h.manager.stateMutex.Unlock() + + found := false + inhibitors := h.manager.state.Screensaver.Inhibitors + for i, inh := range inhibitors { + if inh.Cookie != cookie { + continue + } + log.Infof("Screensaver uninhibited by %s (%s) cookie %08X", inh.AppName, sender, cookie) + h.manager.state.Screensaver.Inhibitors = append(inhibitors[:i], inhibitors[i+1:]...) + found = true + break + } + + if !found { + log.Debugf("UnInhibit: no match for cookie %08X", cookie) + return nil + } + + h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0 + + go h.manager.NotifyScreensaverSubscribers() + + return nil +} + +func (m *Manager) watchPeerDisconnects() { + if m.sessionConn == nil { + return + } + + if err := m.sessionConn.AddMatchSignal( + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ); err != nil { + log.Warnf("Failed to watch peer disconnects: %v", err) + return + } + + signals := make(chan *dbus.Signal, 64) + m.sessionConn.Signal(signals) + + for sig := range signals { + if sig.Name != "org.freedesktop.DBus.NameOwnerChanged" { + continue + } + if len(sig.Body) < 3 { + continue + } + + name, ok1 := sig.Body[0].(string) + newOwner, ok2 := sig.Body[2].(string) + if !ok1 || !ok2 { + continue + } + if newOwner != "" { + continue + } + + m.removeInhibitorsByPeer(name) + } +} + +func (m *Manager) removeInhibitorsByPeer(peer string) { + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + var remaining []ScreensaverInhibitor + var removed []ScreensaverInhibitor + for _, inh := range m.state.Screensaver.Inhibitors { + if inh.Peer == peer { + removed = append(removed, inh) + continue + } + remaining = append(remaining, inh) + } + + if len(removed) == 0 { + return + } + + for _, inh := range removed { + log.Infof("Screensaver: peer %s died, removing inhibitor from %s (cookie %08X)", peer, inh.AppName, inh.Cookie) + } + + m.state.Screensaver.Inhibitors = remaining + m.state.Screensaver.Inhibited = len(remaining) > 0 + + go m.NotifyScreensaverSubscribers() +} + +func (m *Manager) GetScreensaverState() ScreensaverState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return m.state.Screensaver +} + +func (m *Manager) SubscribeScreensaver(id string) chan ScreensaverState { + ch := make(chan ScreensaverState, 64) + m.screensaverSubscribers.Store(id, ch) + return ch +} + +func (m *Manager) UnsubscribeScreensaver(id string) { + if val, ok := m.screensaverSubscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) NotifyScreensaverSubscribers() { + state := m.GetScreensaverState() + m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (h *screensaverHandler) GetActive() (bool, *dbus.Error) { + h.manager.stateMutex.RLock() + active := h.manager.state.Screensaver.Active + h.manager.stateMutex.RUnlock() + return active, nil +} + +func (h *screensaverHandler) SetActive(active bool) *dbus.Error { + h.manager.SetScreenLockActive(active) + return nil +} + +func (h *screensaverHandler) Lock() *dbus.Error { + h.manager.SetScreenLockActive(true) + return nil +} + +func (m *Manager) SetScreenLockActive(active bool) { + m.stateMutex.Lock() + changed := m.state.Screensaver.Active != active + m.state.Screensaver.Active = active + m.stateMutex.Unlock() + + if !changed { + return + } + + log.Infof("Screen lock active changed: %v", active) + defer m.NotifyScreensaverSubscribers() + + if m.sessionConn == nil { + return + } + if m.screensaverFreedesktopClaimed { + if err := m.sessionConn.Emit(dbusScreensaverPath, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath, err) + } + if err := m.sessionConn.Emit(dbusScreensaverPath2, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath2, err) + } + } + if m.screensaverGnomeClaimed { + if err := m.sessionConn.Emit(dbusGnomeScreensaverPath, dbusGnomeScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusGnomeScreensaverPath, err) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go new file mode 100644 index 0000000..324c17e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go @@ -0,0 +1,102 @@ +package freedesktop + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSetScreenLockActive_ChangesState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.False(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(true) + assert.True(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(false) + assert.False(t, manager.GetScreensaverState().Active) +} + +func TestSetScreenLockActive_NoChangeNoDuplicate(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + // Setting to same value should not notify + manager.SetScreenLockActive(false) + + select { + case <-ch: + t.Fatal("should not have received notification for no-change") + case <-time.After(50 * time.Millisecond): + // Expected: no notification + } +} + +func TestSetScreenLockActive_NotifiesSubscribers(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + manager.SetScreenLockActive(true) + + select { + case state := <-ch: + assert.True(t, state.Active) + case <-time.After(time.Second): + t.Fatal("timeout waiting for subscriber notification") + } +} + +func TestSetScreenLockActive_NilSessionConn(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.NotPanics(t, func() { + manager.SetScreenLockActive(true) + }) + assert.True(t, manager.GetScreensaverState().Active) +} + +func TestGetActive_ReturnsCurrentState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: true}, + }, + stateMutex: sync.RWMutex{}, + } + + handler := &screensaverHandler{manager: manager} + active, dbusErr := handler.GetActive() + assert.Nil(t, dbusErr) + assert.True(t, active) +} + +func TestScreensaverState_ActiveDefaultsFalse(t *testing.T) { + state := ScreensaverState{} + assert.False(t, state.Active) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go new file mode 100644 index 0000000..0a9517a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go @@ -0,0 +1,66 @@ +package freedesktop + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type AccountsState struct { + Available bool `json:"available"` + UserPath string `json:"userPath"` + IconFile string `json:"iconFile"` + RealName string `json:"realName"` + UserName string `json:"userName"` + AccountType int32 `json:"accountType"` + HomeDirectory string `json:"homeDirectory"` + Shell string `json:"shell"` + Email string `json:"email"` + Language string `json:"language"` + Location string `json:"location"` + Locked bool `json:"locked"` + PasswordMode int32 `json:"passwordMode"` + UID uint64 `json:"uid"` +} + +type SettingsState struct { + Available bool `json:"available"` + ColorScheme uint32 `json:"colorScheme"` +} + +type ScreensaverInhibitor struct { + Cookie uint32 `json:"cookie"` + AppName string `json:"appName"` + Reason string `json:"reason"` + Peer string `json:"peer"` + StartTime int64 `json:"startTime"` +} + +type ScreensaverState struct { + Available bool `json:"available"` + Active bool `json:"active"` + Inhibited bool `json:"inhibited"` + Inhibitors []ScreensaverInhibitor `json:"inhibitors"` +} + +type FreedeskState struct { + Accounts AccountsState `json:"accounts"` + Settings SettingsState `json:"settings"` + Screensaver ScreensaverState `json:"screensaver"` +} + +type Manager struct { + state *FreedeskState + stateMutex sync.RWMutex + systemConn *dbus.Conn + sessionConn *dbus.Conn + accountsObj dbus.BusObject + settingsObj dbus.BusObject + currentUID uint64 + subscribers syncmap.Map[string, chan FreedeskState] + screensaverSubscribers syncmap.Map[string, chan ScreensaverState] + screensaverCookieCounter uint32 + screensaverFreedesktopClaimed bool + screensaverGnomeClaimed bool +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go new file mode 100644 index 0000000..deb0327 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go @@ -0,0 +1,70 @@ +package freedesktop + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccountsState_Struct(t *testing.T) { + state := AccountsState{ + Available: true, + UserPath: "/org/freedesktop/Accounts/User1000", + RealName: "Test User", + UserName: "testuser", + Locked: false, + UID: 1000, + } + + assert.True(t, state.Available) + assert.Equal(t, "/org/freedesktop/Accounts/User1000", state.UserPath) + assert.Equal(t, "Test User", state.RealName) + assert.Equal(t, "testuser", state.UserName) + assert.Equal(t, uint64(1000), state.UID) + assert.False(t, state.Locked) +} + +func TestSettingsState_Struct(t *testing.T) { + state := SettingsState{ + Available: true, + ColorScheme: 1, // Dark mode + } + + assert.True(t, state.Available) + assert.Equal(t, uint32(1), state.ColorScheme) +} + +func TestFreedeskState_Struct(t *testing.T) { + state := FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 0, // Light mode + }, + } + + assert.True(t, state.Accounts.Available) + assert.Equal(t, "testuser", state.Accounts.UserName) + assert.True(t, state.Settings.Available) + assert.Equal(t, uint32(0), state.Settings.ColorScheme) +} + +func TestAccountsState_DefaultValues(t *testing.T) { + state := AccountsState{} + + assert.False(t, state.Available) + assert.Empty(t, state.UserPath) + assert.Empty(t, state.UserName) + assert.Equal(t, uint64(0), state.UID) +} + +func TestSettingsState_DefaultValues(t *testing.T) { + state := SettingsState{} + + assert.False(t, state.Available) + assert.Equal(t, uint32(0), state.ColorScheme) +} |