summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev
diff options
context:
space:
mode:
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go20
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go130
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go413
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go344
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go6
5 files changed, 913 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go
new file mode 100644
index 0000000..54a7bca
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go
@@ -0,0 +1,20 @@
+package evdev
+
+import (
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "evdev.getState":
+ handleGetState(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go
new file mode 100644
index 0000000..f9bd21d
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go
@@ -0,0 +1,130 @@
+package evdev
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+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 TestHandleRequest(t *testing.T) {
+ t.Run("getState request", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: true},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 123,
+ Method: "evdev.getState",
+ Params: map[string]any{},
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[State]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Available)
+ assert.True(t, resp.Result.CapsLock)
+ })
+
+ t.Run("unknown method", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 456,
+ Method: "evdev.unknownMethod",
+ Params: map[string]any{},
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 456, resp.ID)
+ assert.NotEmpty(t, resp.Error)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+}
+
+func TestHandleGetState(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ conn := newMockNetConn()
+ req := models.Request{
+ ID: 789,
+ Method: "evdev.getState",
+ Params: map[string]any{},
+ }
+
+ handleGetState(conn, req, m)
+
+ var resp models.Response[State]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 789, resp.ID)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Available)
+ assert.False(t, resp.Result.CapsLock)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go
new file mode 100644
index 0000000..b578481
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go
@@ -0,0 +1,413 @@
+package evdev
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/fsnotify/fsnotify"
+ evdev "github.com/holoplot/go-evdev"
+)
+
+const (
+ evKeyType = 0x01
+ evLedType = 0x11
+ keyCapslockKey = 58
+ ledCapslockKey = 1
+ keyStateOn = 1
+)
+
+type EvdevDevice interface {
+ Name() (string, error)
+ Path() string
+ Close() error
+ ReadOne() (*evdev.InputEvent, error)
+ State(t evdev.EvType) (evdev.StateMap, error)
+}
+
+type Manager struct {
+ devices []EvdevDevice
+ devicesMutex sync.RWMutex
+ monitoredPaths map[string]bool
+ state State
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan State]
+ closeChan chan struct{}
+ closeOnce sync.Once
+ watcher *fsnotify.Watcher
+}
+
+func NewManager() (*Manager, error) {
+ devices, err := findKeyboards()
+ if err != nil {
+ return nil, fmt.Errorf("failed to find keyboards: %w", err)
+ }
+
+ initialCapsLock := readInitialCapsLockState(devices[0])
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Warnf("Failed to create fsnotify watcher, hotplug detection disabled: %v", err)
+ watcher = nil
+ } else if err := watcher.Add("/dev/input"); err != nil {
+ log.Warnf("Failed to watch /dev/input, hotplug detection disabled: %v", err)
+ watcher.Close()
+ watcher = nil
+ }
+
+ monitoredPaths := make(map[string]bool)
+ for _, device := range devices {
+ monitoredPaths[device.Path()] = true
+ }
+
+ m := &Manager{
+ devices: devices,
+ monitoredPaths: monitoredPaths,
+ state: State{Available: true, CapsLock: initialCapsLock},
+
+ closeChan: make(chan struct{}),
+ watcher: watcher,
+ }
+
+ for i, device := range devices {
+ go m.monitorDevice(device, i)
+ }
+
+ if watcher != nil {
+ go m.watchForNewKeyboards()
+ }
+
+ return m, nil
+}
+
+func readInitialCapsLockState(device EvdevDevice) bool {
+ ledStates, err := device.State(evLedType)
+ if err != nil {
+ log.Debugf("Could not read LED state: %v", err)
+ return false
+ }
+
+ return ledStates[ledCapslockKey]
+}
+
+func findKeyboards() ([]EvdevDevice, error) {
+ pattern := "/dev/input/event*"
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("failed to glob input devices: %w", err)
+ }
+
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no input devices found")
+ }
+
+ var keyboards []EvdevDevice
+ for _, path := range matches {
+ device, err := evdev.Open(path)
+ if err != nil {
+ continue
+ }
+
+ if !isKeyboard(device) {
+ device.Close()
+ continue
+ }
+
+ deviceName, _ := device.Name()
+ log.Debugf("Found keyboard: %s at %s", deviceName, path)
+ keyboards = append(keyboards, device)
+ }
+
+ if len(keyboards) == 0 {
+ return nil, fmt.Errorf("no keyboard device found")
+ }
+
+ return keyboards, nil
+}
+
+func isKeyboard(device EvdevDevice) bool {
+ deviceName, err := device.Name()
+ if err != nil {
+ return false
+ }
+
+ name := strings.ToLower(deviceName)
+
+ switch {
+ case strings.Contains(name, "keyboard"):
+ return true
+ case strings.Contains(name, "kbd"):
+ return true
+ case strings.Contains(name, "input") && strings.Contains(name, "key"):
+ return true
+ }
+
+ keyStates, err := device.State(evKeyType)
+ if err != nil {
+ return false
+ }
+
+ hasKeyA := len(keyStates) > 30
+ hasKeyZ := len(keyStates) > 44
+ hasEnter := len(keyStates) > 28
+
+ return hasKeyA && hasKeyZ && hasEnter && len(keyStates) > 100
+}
+
+func (m *Manager) watchForNewKeyboards() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("Panic in keyboard hotplug monitor: %v", r)
+ }
+ }()
+
+ for {
+ select {
+ case <-m.closeChan:
+ return
+ case event, ok := <-m.watcher.Events:
+ if !ok {
+ return
+ }
+
+ if !strings.HasPrefix(filepath.Base(event.Name), "event") {
+ continue
+ }
+
+ if event.Op&fsnotify.Create == fsnotify.Create {
+ time.Sleep(100 * time.Millisecond)
+
+ m.devicesMutex.Lock()
+ if m.monitoredPaths[event.Name] {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ device, err := evdev.Open(event.Name)
+ if err != nil {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ if !isKeyboard(device) {
+ device.Close()
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ deviceName, _ := device.Name()
+ log.Debugf("Hotplugged keyboard: %s at %s", deviceName, event.Name)
+
+ m.devices = append(m.devices, device)
+ m.monitoredPaths[event.Name] = true
+ deviceIndex := len(m.devices) - 1
+ m.devicesMutex.Unlock()
+
+ go m.monitorDevice(device, deviceIndex)
+ } else if event.Op&fsnotify.Remove == fsnotify.Remove {
+ m.devicesMutex.Lock()
+ if !m.monitoredPaths[event.Name] {
+ m.devicesMutex.Unlock()
+ continue
+ }
+
+ delete(m.monitoredPaths, event.Name)
+ for i, device := range m.devices {
+ if device != nil && device.Path() == event.Name {
+ log.Debugf("Keyboard removed: %s", event.Name)
+ device.Close()
+ m.devices[i] = nil
+ break
+ }
+ }
+ m.devicesMutex.Unlock()
+ }
+
+ case err, ok := <-m.watcher.Errors:
+ if !ok {
+ return
+ }
+ log.Warnf("Keyboard hotplug watcher error: %v", err)
+ }
+ }
+}
+
+func (m *Manager) monitorDevice(device EvdevDevice, deviceIndex int) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("Panic in evdev monitor: %v", r)
+ }
+ }()
+
+ for {
+ select {
+ case <-m.closeChan:
+ return
+ default:
+ }
+
+ event, err := device.ReadOne()
+ if err != nil {
+ if isClosedError(err) {
+ return
+ }
+ log.Warnf("Failed to read evdev event: %v", err)
+ time.Sleep(100 * time.Millisecond)
+ continue
+ }
+
+ if event == nil {
+ continue
+ }
+
+ if event.Type == evKeyType && event.Code == keyCapslockKey && event.Value == keyStateOn {
+ time.Sleep(50 * time.Millisecond)
+ m.readAndUpdateCapsLockState(deviceIndex)
+ } else if event.Type == evLedType && event.Code == ledCapslockKey {
+ capsLockState := event.Value == keyStateOn
+ m.updateCapsLockStateDirect(capsLockState)
+ }
+ }
+}
+
+func isClosedError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ errStr := err.Error()
+ switch {
+ case strings.Contains(errStr, "closed"):
+ return true
+ case strings.Contains(errStr, "bad file descriptor"):
+ return true
+ default:
+ return false
+ }
+}
+
+func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
+ m.devicesMutex.RLock()
+ if deviceIndex >= len(m.devices) {
+ m.devicesMutex.RUnlock()
+ return
+ }
+ device := m.devices[deviceIndex]
+ m.devicesMutex.RUnlock()
+
+ ledStates, err := device.State(evLedType)
+ if err != nil {
+ log.Warnf("Failed to read LED state: %v", err)
+ return
+ }
+
+ if len(ledStates) == 0 {
+ log.Debug("No LED state available (empty map)")
+
+ // This means the device either:
+ // - doesn't support LED reporting at all, or
+ // - the kernel returned an empty state
+ return
+ }
+
+ capsLockState := ledStates[ledCapslockKey]
+ m.updateCapsLockStateDirect(capsLockState)
+}
+
+func (m *Manager) updateCapsLockStateDirect(capsLockState bool) {
+ m.stateMutex.Lock()
+ if m.state.CapsLock == capsLockState {
+ m.stateMutex.Unlock()
+ return
+ }
+
+ m.state.CapsLock = capsLockState
+ newState := m.state
+ m.stateMutex.Unlock()
+
+ log.Debugf("Caps lock state: %v", newState.CapsLock)
+ m.notifySubscribers(newState)
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 16)
+ 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 State) {
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) Close() {
+ m.closeOnce.Do(func() {
+ close(m.closeChan)
+
+ if m.watcher != nil {
+ m.watcher.Close()
+ }
+
+ m.devicesMutex.Lock()
+ for _, device := range m.devices {
+ if device == nil {
+ continue
+ }
+ if err := device.Close(); err != nil && !isClosedError(err) {
+ log.Warnf("Error closing evdev device: %v", err)
+ }
+ }
+ m.devicesMutex.Unlock()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+ })
+}
+
+func InitializeManager() (*Manager, error) {
+ if os.Getuid() != 0 && !hasInputGroupAccess() {
+ return nil, fmt.Errorf("insufficient permissions to access input devices")
+ }
+
+ return NewManager()
+}
+
+func hasInputGroupAccess() bool {
+ pattern := "/dev/input/event*"
+ matches, err := filepath.Glob(pattern)
+ if err != nil || len(matches) == 0 {
+ return false
+ }
+
+ testFile, err := os.Open(matches[0])
+ if err != nil {
+ return false
+ }
+ testFile.Close()
+ return true
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go
new file mode 100644
index 0000000..667c078
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go
@@ -0,0 +1,344 @@
+package evdev
+
+import (
+ "errors"
+ "testing"
+
+ evdev "github.com/holoplot/go-evdev"
+ "github.com/stretchr/testify/assert"
+
+ mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev"
+)
+
+func TestManager_Creation(t *testing.T) {
+ t.Run("manager created successfully with caps lock off", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ assert.NotNil(t, m)
+ assert.True(t, m.state.Available)
+ assert.False(t, m.state.CapsLock)
+ })
+
+ t.Run("manager created successfully with caps lock on", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: true},
+ closeChan: make(chan struct{}),
+ }
+
+ assert.NotNil(t, m)
+ assert.True(t, m.state.Available)
+ assert.True(t, m.state.CapsLock)
+ })
+}
+
+func TestManager_GetState(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ state := m.GetState()
+ assert.True(t, state.Available)
+ assert.False(t, state.CapsLock)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 1, count)
+
+ m.Unsubscribe("test-client")
+ count = 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+
+ select {
+ case _, ok := <-ch:
+ assert.False(t, ok, "channel should be closed")
+ default:
+ t.Error("channel should be closed")
+ }
+}
+
+func TestManager_UpdateCapsLock(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test-client")
+
+ ledStateOn := evdev.StateMap{ledCapslockKey: true}
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOn, nil).Once()
+
+ go func() {
+ m.readAndUpdateCapsLockState(0)
+ }()
+
+ newState := <-ch
+ assert.True(t, newState.CapsLock)
+
+ ledStateOff := evdev.StateMap{ledCapslockKey: false}
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOff, nil).Once()
+
+ go func() {
+ m.readAndUpdateCapsLockState(0)
+ }()
+
+ newState = <-ch
+ assert.False(t, newState.CapsLock)
+}
+
+func TestManager_Close(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Close().Return(nil).Once()
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch1 := m.Subscribe("client1")
+ ch2 := m.Subscribe("client2")
+
+ m.Close()
+
+ select {
+ case _, ok := <-ch1:
+ assert.False(t, ok, "channel 1 should be closed")
+ default:
+ t.Error("channel 1 should be closed")
+ }
+
+ select {
+ case _, ok := <-ch2:
+ assert.False(t, ok, "channel 2 should be closed")
+ default:
+ t.Error("channel 2 should be closed")
+ }
+
+ count := 0
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ count++
+ return true
+ })
+ assert.Equal(t, 0, count)
+
+ m.Close()
+}
+
+func TestIsKeyboard(t *testing.T) {
+ tests := []struct {
+ name string
+ devName string
+ expected bool
+ }{
+ {"keyboard in name", "AT Translated Set 2 keyboard", true},
+ {"kbd in name", "USB kbd", true},
+ {"input and key", "input key device", true},
+ {"random device", "Mouse", false},
+ {"empty name", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Name().Return(tt.devName, nil).Once()
+
+ if !tt.expected {
+ mockDevice.EXPECT().State(evdev.EvType(evKeyType)).Return(evdev.StateMap{}, nil).Maybe()
+ }
+
+ result := isKeyboard(mockDevice)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestIsKeyboard_ErrorHandling(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().Name().Return("", errors.New("device error")).Once()
+
+ result := isKeyboard(mockDevice)
+ assert.False(t, result)
+}
+
+func TestManager_MonitorDevice(t *testing.T) {
+ t.Run("caps lock key press updates state", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+
+ capsLockEvent := &evdev.InputEvent{
+ Type: evKeyType,
+ Code: keyCapslockKey,
+ Value: keyStateOn,
+ }
+
+ ledState := evdev.StateMap{ledCapslockKey: true}
+
+ mockDevice.EXPECT().ReadOne().Return(capsLockEvent, nil).Once()
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("stop")).Maybe()
+ mockDevice.EXPECT().Close().Return(nil).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch := m.Subscribe("test")
+
+ go m.monitorDevice(mockDevice, 0)
+
+ state := <-ch
+ assert.True(t, state.CapsLock)
+
+ m.Close()
+ })
+}
+
+func TestIsClosedError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expected bool
+ }{
+ {"nil error", nil, false},
+ {"closed error", errors.New("device closed"), true},
+ {"bad file descriptor", errors.New("bad file descriptor"), true},
+ {"other error", errors.New("some other error"), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isClosedError(tt.err)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestNotifySubscribers(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
+ mockDevice.EXPECT().Close().Return(nil).Maybe()
+
+ m := &Manager{
+ devices: []EvdevDevice{mockDevice},
+ monitoredPaths: make(map[string]bool),
+ state: State{Available: true, CapsLock: false},
+ closeChan: make(chan struct{}),
+ }
+
+ ch1 := m.Subscribe("client1")
+ ch2 := m.Subscribe("client2")
+
+ newState := State{Available: true, CapsLock: true}
+ go m.notifySubscribers(newState)
+
+ state1 := <-ch1
+ state2 := <-ch2
+
+ assert.Equal(t, newState, state1)
+ assert.Equal(t, newState, state2)
+
+ m.Close()
+}
+
+func TestReadInitialCapsLockState(t *testing.T) {
+ t.Run("caps lock is on", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ ledState := evdev.StateMap{
+ ledCapslockKey: true,
+ }
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.True(t, result)
+ })
+
+ t.Run("caps lock is off", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ ledState := evdev.StateMap{
+ ledCapslockKey: false,
+ }
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.False(t, result)
+ })
+
+ t.Run("error reading LED state", func(t *testing.T) {
+ mockDevice := mocks.NewMockEvdevDevice(t)
+ mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
+
+ result := readInitialCapsLockState(mockDevice)
+ assert.False(t, result)
+ })
+}
+
+func TestHasInputGroupAccess(t *testing.T) {
+ result := hasInputGroupAccess()
+ t.Logf("hasInputGroupAccess: %v", result)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go
new file mode 100644
index 0000000..df8ec9e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go
@@ -0,0 +1,6 @@
+package evdev
+
+type State struct {
+ Available bool `json:"available"`
+ CapsLock bool `json:"capsLock"`
+}