diff --git a/core/.mockery.yml b/core/.mockery.yml index a526387c..0e439e69 100644 --- a/core/.mockery.yml +++ b/core/.mockery.yml @@ -40,3 +40,9 @@ packages: outpkg: mocks_cups interfaces: CUPSClientInterface: + github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev: + config: + dir: "internal/mocks/evdev" + outpkg: mocks_evdev + interfaces: + EvdevDevice: diff --git a/core/README.md b/core/README.md index d0d9ab8e..b28aad9c 100644 --- a/core/README.md +++ b/core/README.md @@ -31,6 +31,7 @@ Distribution-aware installer with TUI for deploying DMS and compositor configura - DDC/CI protocol - External monitor brightness control (like `ddcutil`) - Backlight control - Internal display brightness via `login1` or sysfs - LED control - Keyboard/device LED management +- evdev input monitoring - Keyboard state tracking (caps lock, etc.) **Plugin System** - Plugin registry integration diff --git a/core/go.mod b/core/go.mod index 216d333c..683e92bb 100644 --- a/core/go.mod +++ b/core/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.2 github.com/godbus/dbus/v5 v5.1.0 + github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34 @@ -61,7 +62,6 @@ require ( github.com/spf13/afero v1.15.0 github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/core/go.sum b/core/go.sum index 0db07571..c7f73ffd 100644 --- a/core/go.sum +++ b/core/go.sum @@ -14,12 +14,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -28,16 +24,10 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= @@ -49,8 +39,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -66,25 +54,23 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA= -github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM= github.com/go-git/go-billy/v6 v6.0.0-20251111123000-fb5ff8f3f0b0 h1:EC9n6hr6yKDoVJ6g7Ko523LbbceJfR0ohbOp809Fyf4= github.com/go-git/go-billy/v6 v6.0.0-20251111123000-fb5ff8f3f0b0/go.mod h1:E3VhlS+AKkrq6ZNn1axE2/nDRJ87l1FJk9r5HT2vPX0= github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= -github.com/go-git/go-git/v6 v6.0.0-20250929195514-145daf2492dd h1:30HEd5KKVM7GgMJ1GSNuYxuZXEg8Pdlngp6T51faxoc= -github.com/go-git/go-git/v6 v6.0.0-20250929195514-145daf2492dd/go.mod h1:lz8PQr/p79XpFq5ODVBwRJu5LnOF8Et7j95ehqmCMJU= github.com/go-git/go-git/v6 v6.0.0-20251112161705-8cc3e21f07a9 h1:SOFrnF9LCssC6q6Rb0084Bzg2aBYbe8QXv9xKGXmt/w= github.com/go-git/go-git/v6 v6.0.0-20251112161705-8cc3e21f07a9/go.mod h1:0wtvm/JfPC9RFVEAP3ks0ec5h64/qmZkTTUE3pjz7Hc= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU= +github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= @@ -103,8 +89,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -117,7 +101,6 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -127,18 +110,12 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -148,33 +125,18 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34 h1:iTAt1me6SBYsuzrl/CmrxtATPlOG/pVviosM3DhUdKE= github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34/go.mod h1:jzmUN5lUAv2O8e63OvcauV4S30rIZ1BvF/PNYE37vDo= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/core/internal/mocks/evdev/mock_EvdevDevice.go b/core/internal/mocks/evdev/mock_EvdevDevice.go new file mode 100644 index 00000000..4d54e3e5 --- /dev/null +++ b/core/internal/mocks/evdev/mock_EvdevDevice.go @@ -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 +} diff --git a/core/internal/server/evdev/handlers.go b/core/internal/server/evdev/handlers.go new file mode 100644 index 00000000..9f9ed6d8 --- /dev/null +++ b/core/internal/server/evdev/handlers.go @@ -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) +} diff --git a/core/internal/server/evdev/handlers_test.go b/core/internal/server/evdev/handlers_test.go new file mode 100644 index 00000000..ac558472 --- /dev/null +++ b/core/internal/server/evdev/handlers_test.go @@ -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) +} diff --git a/core/internal/server/evdev/manager.go b/core/internal/server/evdev/manager.go new file mode 100644 index 00000000..3876c4ec --- /dev/null +++ b/core/internal/server/evdev/manager.go @@ -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 +} diff --git a/core/internal/server/evdev/manager_test.go b/core/internal/server/evdev/manager_test.go new file mode 100644 index 00000000..15cb10e3 --- /dev/null +++ b/core/internal/server/evdev/manager_test.go @@ -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) +} diff --git a/core/internal/server/evdev/models.go b/core/internal/server/evdev/models.go new file mode 100644 index 00000000..df8ec9e6 --- /dev/null +++ b/core/internal/server/evdev/models.go @@ -0,0 +1,6 @@ +package evdev + +type State struct { + Available bool `json:"available"` + CapsLock bool `json:"capsLock"` +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 996deeb0..836f177f 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -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") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 5f699a5e..c02dac5c 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -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") diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 3ec906a9..6f1c7b40 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -600,6 +600,14 @@ Item { } } + Variants { + model: SettingsData.getFilteredScreens("osd") + + delegate: CapsLockOSD { + modelData: item + } + } + LazyLoader { id: hyprlandOverviewLoader active: CompositorService.isHyprland diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 6095eb34..9571aba1 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -332,6 +332,7 @@ Item { } ColumnLayout { + id: passwordLayout anchors.centerIn: parent anchors.verticalCenterOffset: 50 spacing: Theme.spacingM @@ -740,6 +741,35 @@ Item { } } + Row { + anchors.top: passwordLayout.bottom + anchors.topMargin: Theme.spacingS + anchors.horizontalCenter: passwordLayout.horizontalCenter + spacing: 4 + opacity: DMSService.capsLockState ? 1 : 0 + + DankIcon { + name: "shift_lock" + size: 14 + color: Theme.error + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: "Caps Lock is on" + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + anchors.verticalCenter: parent.verticalCenter + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + StyledText { anchors.top: parent.top anchors.left: parent.left diff --git a/quickshell/Modules/OSD/CapsLockOSD.qml b/quickshell/Modules/OSD/CapsLockOSD.qml new file mode 100644 index 00000000..03e268c1 --- /dev/null +++ b/quickshell/Modules/OSD/CapsLockOSD.qml @@ -0,0 +1,37 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +DankOSD { + id: root + + osdWidth: Theme.iconSize + Theme.spacingS * 2 + osdHeight: Theme.iconSize + Theme.spacingS * 2 + autoHideInterval: 2000 + enableMouseInteraction: false + + property bool lastCapsLockState: false + + Connections { + target: DMSService + + function onCapsLockStateChanged() { + if (lastCapsLockState !== DMSService.capsLockState) { + root.show() + } + lastCapsLockState = DMSService.capsLockState + } + } + + Component.onCompleted: { + lastCapsLockState = DMSService.capsLockState + } + + content: DankIcon { + anchors.centerIn: parent + name: DMSService.capsLockState ? "shift_lock" : "shift_lock_off" + size: Theme.iconSize + color: Theme.primary + } +} diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 6c74377d..180b88e9 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -49,8 +49,11 @@ Singleton { signal brightnessDeviceUpdate(var device) signal extWorkspaceStateUpdate(var data) signal wlrOutputStateUpdate(var data) + signal evdevStateUpdate(var data) - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput"] + property bool capsLockState: false + + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -349,6 +352,11 @@ Singleton { extWorkspaceStateUpdate(data) } else if (service === "wlroutput") { wlrOutputStateUpdate(data) + } else if (service === "evdev") { + if (data.capsLock !== undefined) { + capsLockState = data.capsLock + } + evdevStateUpdate(data) } }