mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
evdev: add evdev monitor for caps lock state
This commit is contained in:
295
core/internal/mocks/evdev/mock_EvdevDevice.go
Normal file
295
core/internal/mocks/evdev/mock_EvdevDevice.go
Normal file
@@ -0,0 +1,295 @@
|
||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package mocks_evdev
|
||||
|
||||
import (
|
||||
go_evdev "github.com/holoplot/go-evdev"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockEvdevDevice is an autogenerated mock type for the EvdevDevice type
|
||||
type MockEvdevDevice struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockEvdevDevice_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockEvdevDevice) EXPECT() *MockEvdevDevice_Expecter {
|
||||
return &MockEvdevDevice_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Close provides a mock function with no fields
|
||||
func (_m *MockEvdevDevice) Close() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Close")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockEvdevDevice_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
|
||||
type MockEvdevDevice_Close_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Close is a helper method to define mock.On call
|
||||
func (_e *MockEvdevDevice_Expecter) Close() *MockEvdevDevice_Close_Call {
|
||||
return &MockEvdevDevice_Close_Call{Call: _e.mock.On("Close")}
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Close_Call) Run(run func()) *MockEvdevDevice_Close_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Close_Call) Return(_a0 error) *MockEvdevDevice_Close_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Close_Call) RunAndReturn(run func() error) *MockEvdevDevice_Close_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Name provides a mock function with no fields
|
||||
func (_m *MockEvdevDevice) Name() (string, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Name")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func() (string, error)); ok {
|
||||
return rf()
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockEvdevDevice_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'
|
||||
type MockEvdevDevice_Name_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Name is a helper method to define mock.On call
|
||||
func (_e *MockEvdevDevice_Expecter) Name() *MockEvdevDevice_Name_Call {
|
||||
return &MockEvdevDevice_Name_Call{Call: _e.mock.On("Name")}
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Name_Call) Run(run func()) *MockEvdevDevice_Name_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Name_Call) Return(_a0 string, _a1 error) *MockEvdevDevice_Name_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Name_Call) RunAndReturn(run func() (string, error)) *MockEvdevDevice_Name_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Path provides a mock function with no fields
|
||||
func (_m *MockEvdevDevice) Path() string {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Path")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockEvdevDevice_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
|
||||
type MockEvdevDevice_Path_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Path is a helper method to define mock.On call
|
||||
func (_e *MockEvdevDevice_Expecter) Path() *MockEvdevDevice_Path_Call {
|
||||
return &MockEvdevDevice_Path_Call{Call: _e.mock.On("Path")}
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Path_Call) Run(run func()) *MockEvdevDevice_Path_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Path_Call) Return(_a0 string) *MockEvdevDevice_Path_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_Path_Call) RunAndReturn(run func() string) *MockEvdevDevice_Path_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ReadOne provides a mock function with no fields
|
||||
func (_m *MockEvdevDevice) ReadOne() (*go_evdev.InputEvent, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ReadOne")
|
||||
}
|
||||
|
||||
var r0 *go_evdev.InputEvent
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func() (*go_evdev.InputEvent, error)); ok {
|
||||
return rf()
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func() *go_evdev.InputEvent); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*go_evdev.InputEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockEvdevDevice_ReadOne_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadOne'
|
||||
type MockEvdevDevice_ReadOne_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ReadOne is a helper method to define mock.On call
|
||||
func (_e *MockEvdevDevice_Expecter) ReadOne() *MockEvdevDevice_ReadOne_Call {
|
||||
return &MockEvdevDevice_ReadOne_Call{Call: _e.mock.On("ReadOne")}
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_ReadOne_Call) Run(run func()) *MockEvdevDevice_ReadOne_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_ReadOne_Call) Return(_a0 *go_evdev.InputEvent, _a1 error) *MockEvdevDevice_ReadOne_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_ReadOne_Call) RunAndReturn(run func() (*go_evdev.InputEvent, error)) *MockEvdevDevice_ReadOne_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// State provides a mock function with given fields: t
|
||||
func (_m *MockEvdevDevice) State(t go_evdev.EvType) (go_evdev.StateMap, error) {
|
||||
ret := _m.Called(t)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for State")
|
||||
}
|
||||
|
||||
var r0 go_evdev.StateMap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(go_evdev.EvType) (go_evdev.StateMap, error)); ok {
|
||||
return rf(t)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(go_evdev.EvType) go_evdev.StateMap); ok {
|
||||
r0 = rf(t)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(go_evdev.StateMap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(go_evdev.EvType) error); ok {
|
||||
r1 = rf(t)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockEvdevDevice_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State'
|
||||
type MockEvdevDevice_State_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// State is a helper method to define mock.On call
|
||||
// - t go_evdev.EvType
|
||||
func (_e *MockEvdevDevice_Expecter) State(t interface{}) *MockEvdevDevice_State_Call {
|
||||
return &MockEvdevDevice_State_Call{Call: _e.mock.On("State", t)}
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_State_Call) Run(run func(t go_evdev.EvType)) *MockEvdevDevice_State_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(go_evdev.EvType))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_State_Call) Return(_a0 go_evdev.StateMap, _a1 error) *MockEvdevDevice_State_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvdevDevice_State_Call) RunAndReturn(run func(go_evdev.EvType) (go_evdev.StateMap, error)) *MockEvdevDevice_State_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockEvdevDevice creates a new instance of MockEvdevDevice. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockEvdevDevice(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockEvdevDevice {
|
||||
mock := &MockEvdevDevice{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
27
core/internal/server/evdev/handlers.go
Normal file
27
core/internal/server/evdev/handlers.go
Normal 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)
|
||||
}
|
||||
133
core/internal/server/evdev/handlers_test.go
Normal file
133
core/internal/server/evdev/handlers_test.go
Normal 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)
|
||||
}
|
||||
256
core/internal/server/evdev/manager.go
Normal file
256
core/internal/server/evdev/manager.go
Normal 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
|
||||
}
|
||||
314
core/internal/server/evdev/manager_test.go
Normal file
314
core/internal/server/evdev/manager_test.go
Normal 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)
|
||||
}
|
||||
6
core/internal/server/evdev/models.go
Normal file
6
core/internal/server/evdev/models.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package evdev
|
||||
|
||||
type State struct {
|
||||
Available bool `json:"available"`
|
||||
CapsLock bool `json:"capsLock"`
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
@@ -165,6 +166,20 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "evdev.") {
|
||||
if evdevManager == nil {
|
||||
models.RespondError(conn, req.ID, "evdev manager not initialized")
|
||||
return
|
||||
}
|
||||
evdevReq := evdev.Request{
|
||||
ID: req.ID,
|
||||
Method: req.Method,
|
||||
Params: req.Params,
|
||||
}
|
||||
evdev.HandleRequest(conn, evdevReq, evdevManager)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
models.Respond(conn, req.ID, "pong")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
@@ -28,7 +29,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||
)
|
||||
|
||||
const APIVersion = 17
|
||||
const APIVersion = 18
|
||||
|
||||
type Capabilities struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -54,6 +55,7 @@ var dwlManager *dwl.Manager
|
||||
var extWorkspaceManager *extworkspace.Manager
|
||||
var brightnessManager *brightness.Manager
|
||||
var wlrOutputManager *wlroutput.Manager
|
||||
var evdevManager *evdev.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
|
||||
var capabilitySubscribers = make(map[string]chan ServerInfo)
|
||||
@@ -292,6 +294,19 @@ func InitializeWlrOutputManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeEvdevManager() error {
|
||||
manager, err := evdev.InitializeManager()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize evdev manager: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
evdevManager = manager
|
||||
|
||||
log.Info("Evdev manager initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -358,6 +373,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "wlroutput")
|
||||
}
|
||||
|
||||
if evdevManager != nil {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
return Capabilities{Capabilities: caps}
|
||||
}
|
||||
|
||||
@@ -404,6 +423,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "wlroutput")
|
||||
}
|
||||
|
||||
if evdevManager != nil {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
return ServerInfo{
|
||||
APIVersion: APIVersion,
|
||||
Capabilities: caps,
|
||||
@@ -918,6 +941,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("evdev") && evdevManager != nil {
|
||||
wg.Add(1)
|
||||
evdevChan := evdevManager.Subscribe(clientID + "-evdev")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer evdevManager.Unsubscribe(clientID + "-evdev")
|
||||
|
||||
initialState := evdevManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "evdev", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-evdevChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "evdev", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(eventChan)
|
||||
@@ -974,6 +1029,9 @@ func cleanupManagers() {
|
||||
if wlrOutputManager != nil {
|
||||
wlrOutputManager.Close()
|
||||
}
|
||||
if evdevManager != nil {
|
||||
evdevManager.Close()
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Close()
|
||||
}
|
||||
@@ -1122,6 +1180,9 @@ func Start(printDocs bool) error {
|
||||
log.Info(" - transform : Transform value (optional)")
|
||||
log.Info(" - scale : Scale value (optional)")
|
||||
log.Info(" - adaptiveSync : Adaptive sync state (optional)")
|
||||
log.Info("Evdev:")
|
||||
log.Info(" evdev.getState - Get current evdev state (caps lock)")
|
||||
log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)")
|
||||
log.Info("")
|
||||
}
|
||||
log.Info("Initializing managers...")
|
||||
@@ -1194,10 +1255,8 @@ func Start(printDocs bool) error {
|
||||
fatalErrChan := make(chan error, 1)
|
||||
if wlrOutputManager != nil {
|
||||
go func() {
|
||||
select {
|
||||
case err := <-wlrOutputManager.FatalError():
|
||||
fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err)
|
||||
}
|
||||
err := <-wlrOutputManager.FatalError()
|
||||
fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1209,6 +1268,14 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := InitializeEvdevManager(); err != nil {
|
||||
log.Debugf("Evdev manager unavailable: %v", err)
|
||||
} else {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}()
|
||||
|
||||
if wlContext != nil {
|
||||
wlContext.Start()
|
||||
log.Info("Wayland event dispatcher started")
|
||||
|
||||
Reference in New Issue
Block a user