initial commit

This commit is contained in:
tumillanino
2025-11-12 23:59:00 +11:00
commit 991cca06bf
274 changed files with 70484 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
package freedesktop
import (
"context"
"fmt"
"os/exec"
"time"
"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 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme")
if err := check.Run(); err == nil {
cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme)
if err := cmd.Run(); err != nil {
return fmt.Errorf("gsettings set failed: %w", err)
}
return nil
}
checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme))
if err := checkDconf.Run(); err != nil {
return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err)
}
return nil
}

View File

@@ -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")
})
}

View File

@@ -0,0 +1,14 @@
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"
)

View File

@@ -0,0 +1,166 @@
package freedesktop
import (
"fmt"
"net"
"github.com/AvengeMedia/danklinux/internal/server/models"
)
type Request struct {
ID int `json:"id,omitempty"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
func HandleRequest(conn net.Conn, req 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 Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleSetIconFile(conn net.Conn, req Request, manager *Manager) {
iconPath, ok := req.Params["path"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'path' parameter")
return
}
if err := manager.SetIconFile(iconPath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "icon file set"})
}
func handleSetRealName(conn net.Conn, req Request, manager *Manager) {
name, ok := req.Params["name"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
if err := manager.SetRealName(name); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "real name set"})
}
func handleSetEmail(conn net.Conn, req Request, manager *Manager) {
email, ok := req.Params["email"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'email' parameter")
return
}
if err := manager.SetEmail(email); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "email set"})
}
func handleSetLanguage(conn net.Conn, req Request, manager *Manager) {
language, ok := req.Params["language"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'language' parameter")
return
}
if err := manager.SetLanguage(language); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "language set"})
}
func handleSetLocation(conn net.Conn, req Request, manager *Manager) {
location, ok := req.Params["location"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'location' parameter")
return
}
if err := manager.SetLocation(location); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "location set"})
}
func handleGetUserIconFile(conn net.Conn, req Request, manager *Manager) {
username, ok := req.Params["username"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'username' parameter")
return
}
iconFile, err := manager.GetUserIconFile(username)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Value: iconFile})
}
func handleGetColorScheme(conn net.Conn, req 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 Request, manager *Manager) {
iconTheme, ok := req.Params["iconTheme"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'iconTheme' parameter")
return
}
if err := manager.SetIconTheme(iconTheme); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "icon theme set"})
}

View File

@@ -0,0 +1,581 @@
package freedesktop
import (
"bytes"
"encoding/json"
"net"
"sync"
"testing"
mockdbus "github.com/AvengeMedia/danklinux/internal/mocks/github.com/godbus/dbus/v5"
"github.com/AvengeMedia/danklinux/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: []interface{}{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 := SuccessResult{Success: true, Message: "test"}
models.Respond(conn, 123, result)
var resp models.Response[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 := 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 := Request{
ID: 123,
Method: "freedesktop.accounts.setIconFile",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.setIconFile",
Params: map[string]interface{}{
"path": "/path/to/icon.png",
},
}
handleSetIconFile(conn, req, manager)
var resp models.Response[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 := Request{
ID: 123,
Method: "freedesktop.accounts.setIconFile",
Params: map[string]interface{}{
"path": "/path/to/icon.png",
},
}
handleSetIconFile(conn, req, manager)
var resp models.Response[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 := Request{
ID: 123,
Method: "freedesktop.accounts.setRealName",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.setRealName",
Params: map[string]interface{}{
"name": "New Name",
},
}
handleSetRealName(conn, req, manager)
var resp models.Response[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 := Request{
ID: 123,
Method: "freedesktop.accounts.setEmail",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.setEmail",
Params: map[string]interface{}{
"email": "test@example.com",
},
}
handleSetEmail(conn, req, manager)
var resp models.Response[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 := Request{
ID: 123,
Method: "freedesktop.accounts.setLanguage",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.setLocation",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.getUserIconFile",
Params: map[string]interface{}{},
}
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 := Request{
ID: 123,
Method: "freedesktop.accounts.getUserIconFile",
Params: map[string]interface{}{
"username": "testuser",
},
}
handleGetUserIconFile(conn, req, manager)
var resp models.Response[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 := 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: []interface{}{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 := 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 := 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 := 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 := Request{
ID: 123,
Method: method,
Params: map[string]interface{}{},
}
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
}
})
}

View File

@@ -0,0 +1,251 @@
package freedesktop
import (
"context"
"fmt"
"os"
"sync"
"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{},
},
stateMutex: sync.RWMutex{},
systemConn: systemConn,
sessionConn: sessionConn,
currentUID: uint64(os.Getuid()),
subscribers: make(map[string]chan FreedeskState),
subMutex: sync.RWMutex{},
}
m.initializeAccounts()
m.initializeSettings()
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()
if v, ok := props["IconFile"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.IconFile = val
}
}
if v, ok := props["RealName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.RealName = val
}
}
if v, ok := props["UserName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.UserName = val
}
}
if v, ok := props["AccountType"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.AccountType = val
}
}
if v, ok := props["HomeDirectory"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.HomeDirectory = val
}
}
if v, ok := props["Shell"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Shell = val
}
}
if v, ok := props["Email"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Email = val
}
}
if v, ok := props["Language"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Language = val
}
}
if v, ok := props["Location"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Location = val
}
}
if v, ok := props["Locked"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Accounts.Locked = val
}
}
if v, ok := props["PasswordMode"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.PasswordMode = val
}
}
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 := variant.Value().(uint32); 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.subMutex.Lock()
m.subscribers[id] = ch
m.subMutex.Unlock()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subMutex.Lock()
if ch, ok := m.subscribers[id]; ok {
close(ch)
delete(m.subscribers, id)
}
m.subMutex.Unlock()
}
func (m *Manager) NotifySubscribers() {
m.subMutex.RLock()
defer m.subMutex.RUnlock()
state := m.GetState()
for _, ch := range m.subscribers {
select {
case ch <- state:
default:
}
}
}
func (m *Manager) Close() {
m.subMutex.Lock()
for id, ch := range m.subscribers {
close(ch)
delete(m.subscribers, id)
}
m.subMutex.Unlock()
if m.systemConn != nil {
m.systemConn.Close()
}
if m.sessionConn != nil {
m.sessionConn.Close()
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,46 @@
package freedesktop
import (
"sync"
"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 FreedeskState struct {
Accounts AccountsState `json:"accounts"`
Settings SettingsState `json:"settings"`
}
type Manager struct {
state *FreedeskState
stateMutex sync.RWMutex
systemConn *dbus.Conn
sessionConn *dbus.Conn
accountsObj dbus.BusObject
settingsObj dbus.BusObject
currentUID uint64
subscribers map[string]chan FreedeskState
subMutex sync.RWMutex
}

View File

@@ -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)
}