1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-25 05:52:50 -05:00

evdev: add evdev monitor for caps lock state

This commit is contained in:
bbedward
2025-11-13 22:24:27 -05:00
parent 6465b11e9b
commit 526c4092fd
16 changed files with 1215 additions and 50 deletions

View File

@@ -0,0 +1,27 @@
package evdev
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type Request struct {
ID interface{} `json:"id"`
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}
func HandleRequest(conn net.Conn, req Request, m *Manager) {
switch req.Method {
case "evdev.getState":
handleGetState(conn, req, m)
default:
models.RespondError(conn, req.ID.(int), "unknown method: "+req.Method)
}
}
func handleGetState(conn net.Conn, req Request, m *Manager) {
state := m.GetState()
models.Respond(conn, req.ID.(int), state)
}

View File

@@ -0,0 +1,133 @@
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{
device: mockDevice,
state: State{Available: true, CapsLock: true},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
conn := newMockNetConn()
req := Request{
ID: 123,
Method: "evdev.getState",
Params: map[string]interface{}{},
}
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
conn := newMockNetConn()
req := Request{
ID: 456,
Method: "evdev.unknownMethod",
Params: map[string]interface{}{},
}
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
conn := newMockNetConn()
req := Request{
ID: 789,
Method: "evdev.getState",
Params: map[string]interface{}{},
}
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)
}

View File

@@ -0,0 +1,256 @@
package evdev
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
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 {
device EvdevDevice
state State
stateMutex sync.RWMutex
subscribers map[string]chan State
subMutex sync.RWMutex
closeChan chan struct{}
closeOnce sync.Once
}
func NewManager() (*Manager, error) {
device, err := findKeyboard()
if err != nil {
return nil, fmt.Errorf("failed to find keyboard: %w", err)
}
initialCapsLock := readInitialCapsLockState(device)
m := &Manager{
device: device,
state: State{Available: true, CapsLock: initialCapsLock},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
go m.monitorCapsLock()
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 findKeyboard() (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")
}
for _, path := range matches {
device, err := evdev.Open(path)
if err != nil {
continue
}
if isKeyboard(device) {
deviceName, _ := device.Name()
log.Debugf("Found keyboard: %s at %s", deviceName, path)
return device, nil
}
device.Close()
}
return nil, fmt.Errorf("no keyboard device found")
}
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
default:
return false
}
}
func (m *Manager) monitorCapsLock() {
defer func() {
if r := recover(); r != nil {
log.Errorf("Panic in evdev monitor: %v", r)
}
}()
for {
select {
case <-m.closeChan:
return
default:
}
event, err := m.device.ReadOne()
if err != nil {
if !isClosedError(err) {
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 {
m.toggleCapsLock()
}
}
}
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) toggleCapsLock() {
m.stateMutex.Lock()
m.state.CapsLock = !m.state.CapsLock
newState := m.state
m.stateMutex.Unlock()
log.Debugf("Caps lock toggled: %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 {
m.subMutex.Lock()
defer m.subMutex.Unlock()
ch := make(chan State, 16)
m.subscribers[id] = ch
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subMutex.Lock()
defer m.subMutex.Unlock()
if ch, ok := m.subscribers[id]; ok {
close(ch)
delete(m.subscribers, id)
}
}
func (m *Manager) notifySubscribers(state State) {
m.subMutex.RLock()
defer m.subMutex.RUnlock()
for _, ch := range m.subscribers {
select {
case ch <- state:
default:
}
}
}
func (m *Manager) Close() {
m.closeOnce.Do(func() {
close(m.closeChan)
if m.device != nil {
if err := m.device.Close(); err != nil && !isClosedError(err) {
log.Warnf("Error closing evdev device: %v", err)
}
}
m.subMutex.Lock()
for id, ch := range m.subscribers {
close(ch)
delete(m.subscribers, id)
}
m.subMutex.Unlock()
})
}
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
}

View File

@@ -0,0 +1,314 @@
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
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{
device: mockDevice,
state: State{Available: true, CapsLock: true},
subscribers: make(map[string]chan State),
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
ch := m.Subscribe("test-client")
assert.NotNil(t, ch)
assert.Len(t, m.subscribers, 1)
}
func TestManager_Unsubscribe(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
m := &Manager{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
ch := m.Subscribe("test-client")
assert.Len(t, m.subscribers, 1)
m.Unsubscribe("test-client")
assert.Len(t, m.subscribers, 0)
select {
case _, ok := <-ch:
assert.False(t, ok, "channel should be closed")
default:
t.Error("channel should be closed")
}
}
func TestManager_ToggleCapsLock(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe()
m := &Manager{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
ch := m.Subscribe("test-client")
go func() {
m.toggleCapsLock()
}()
newState := <-ch
assert.True(t, newState.CapsLock)
go func() {
m.toggleCapsLock()
}()
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
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")
}
assert.Len(t, m.subscribers, 0)
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()
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_MonitorCapsLock(t *testing.T) {
t.Run("caps lock key press toggles state", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
capsLockEvent := &evdev.InputEvent{
Type: evKeyType,
Code: keyCapslockKey,
Value: keyStateOn,
}
mockDevice.EXPECT().ReadOne().Return(capsLockEvent, nil).Once()
mockDevice.EXPECT().ReadOne().Return(nil, errors.New("stop")).Maybe()
mockDevice.EXPECT().Close().Return(nil).Maybe()
m := &Manager{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
closeChan: make(chan struct{}),
}
ch := m.Subscribe("test")
go m.monitorCapsLock()
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{
device: mockDevice,
state: State{Available: true, CapsLock: false},
subscribers: make(map[string]chan State),
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)
}

View File

@@ -0,0 +1,6 @@
package evdev
type State struct {
Available bool `json:"available"`
CapsLock bool `json:"capsLock"`
}