Skip to content

Commit 2199826

Browse files
authored
fix(linux): report when scrolling falls back from uinput on wayland (#1570)
1 parent 3509ed3 commit 2199826

19 files changed

Lines changed: 313 additions & 21 deletions

docs/LINUX_SETUP.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Host changes required before Neru runs correctly (not code changes):
4545
| 1 | Install [build dependencies](#build-dependencies) | CGO backends and runtime libs | All Linux | Yes |
4646
| 2 | Add user to `input` group: `sudo usermod -aG input "$USER"` | `evdev` keyboard capture **and Neru's own global hotkeys** on Wayland | Wayland | Yes (re-login required) |
4747
| 3 | Bind `neru <mode>` in compositor keybindings | Only needed if you skip item 2 | Wayland | Yes (user config) |
48+
| 4 | Make `/dev/uinput` writable (udev rule below) | Scroll injection through a uinput wheel; without it scrolling falls back to the compositor virtual pointer, which Chromium and Electron apps on Hyprland ignore. Also the fast path for `neru key` | Wayland | Yes (udev rule) |
4849

4950
Notes:
5051

@@ -55,6 +56,8 @@ Notes:
5556
the compositor only if you would rather not grant `/dev/input` access. See
5657
[Global hotkeys on Wayland](./LINUX_DESKTOPS.md#global-hotkeys-on-wayland).
5758
- Item 3 cannot be automated by a package; ship example snippets where helpful.
59+
- Item 4 is what `neru doctor` reports under `capability.scroll` when it is
60+
missing, and the daemon warns once at the first scroll that falls back.
5861

5962
---
6063

@@ -78,6 +81,30 @@ works but modified clicks may degrade.
7881

7982
---
8083

84+
## Wayland scroll injection permissions
85+
86+
On Wayland, Neru scrolls through a virtual mouse wheel it creates on
87+
`/dev/uinput`, so the events enter the input stack below the compositor and
88+
reach every client like a physical wheel. Most distros ship that node as
89+
root-only (`crw-rw---- root root`). The `input` group from the previous section
90+
does not cover it; grant it with a udev rule:
91+
92+
```bash
93+
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-neru-uinput.rules
94+
sudo udevadm control --reload && sudo udevadm trigger
95+
```
96+
97+
Confirm with `ls -l /dev/uinput` (group `input`, mode `0660`) and restart the
98+
daemon. If the node is missing entirely, load the module: `sudo modprobe uinput`.
99+
100+
Without it Neru still scrolls, through the compositor's virtual pointer, but
101+
that path is not honored by every client: Chromium and Electron apps on
102+
Hyprland scroll a few pixels and then stop. `neru doctor` reports the downgrade
103+
under `capability.scroll` with the open error, and the daemon logs one warning
104+
at the first scroll that falls back.
105+
106+
---
107+
81108
## Using nix home manager
82109

83110
Minimal flake with Home Manager:

internal/adapter/accessibility/native/linux/element.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"image"
77
"os"
88
"slices"
9+
"sync"
910
"time"
1011

1112
"go.uber.org/zap"
@@ -541,6 +542,10 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
541542
// the compositor and client can process events incrementally.
542543
const maxBatchEvents = 50
543544

545+
// fallbackCause is the uinput error that sends the rest of the scroll
546+
// to the virtual pointer, for the one-time warning below.
547+
var fallbackCause error
548+
544549
sendScaledScroll := func(axis int, delta int) int {
545550
if delta == 0 {
546551
return 0
@@ -570,6 +575,7 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
570575
// remaining delta is retried via wlroots virtual pointer
571576
// fallback without double-counting already-sent notches.
572577
remainingNotches += len(batch)
578+
fallbackCause = err
573579

574580
break
575581
}
@@ -595,6 +601,8 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
595601
return nil
596602
}
597603

604+
warnUinputScrollFallback(fallbackCause)
605+
598606
return wlrootsScrollAtCursor(remainX, remainY, 0)
599607
}
600608

@@ -613,6 +621,28 @@ func scrollAtCursorNow(deltaX, deltaY int, modifiers action.Modifiers) error {
613621
return nil
614622
}
615623

624+
// uinputScrollFallbackOnce keeps the fallback warning to one line per
625+
// process: the condition is a session fact, and every scroll would repeat it.
626+
var uinputScrollFallbackOnce sync.Once
627+
628+
// warnUinputScrollFallback says, once, that scrolling left the uinput wheel
629+
// for the compositor's virtual pointer and why. The two paths look the same
630+
// from the log otherwise, and only the virtual pointer one is ignored by
631+
// Chromium and Electron clients on Hyprland — which is how a user with a
632+
// root-only /dev/uinput reads as "scroll works in Firefox but not Discord".
633+
// cause is the batch error that triggered it: the device creation failure,
634+
// reason included, or a write that failed after a good start.
635+
func warnUinputScrollFallback(cause error) {
636+
uinputScrollFallbackOnce.Do(func() {
637+
currentLogger().Warn(
638+
"Scrolling through the wlroots virtual pointer instead of uinput; "+
639+
"some clients ignore that path (grant write access to /dev/uinput, "+
640+
"see docs/LINUX_SETUP.md)",
641+
zap.Error(cause),
642+
)
643+
})
644+
}
645+
616646
// hyprlandKeepsUinputScroll reports whether a modified scroll on this session
617647
// keeps the uinput batch and presses the modifier beside it, rather than moving
618648
// the whole scroll onto the wlroots virtual pointer.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//go:build linux
2+
3+
package linux
4+
5+
import (
6+
"sync"
7+
8+
"go.uber.org/zap"
9+
)
10+
11+
// pkgLogger is the process-global logger for the Linux injection backends,
12+
// a slot beside configProvider for the same reason: the scroll path picks its
13+
// backend at call time, deep below any struct that carries a logger, and the
14+
// one thing worth saying from there is which backend it ended up on.
15+
var (
16+
pkgLoggerMu sync.RWMutex
17+
pkgLogger = zap.NewNop()
18+
)
19+
20+
// SetLogger installs the logger the Linux injection backends report through.
21+
// It is set once at daemon startup (see internal/app/runtime_config_linux.go).
22+
func SetLogger(logger *zap.Logger) {
23+
if logger == nil {
24+
logger = zap.NewNop()
25+
}
26+
27+
pkgLoggerMu.Lock()
28+
pkgLogger = logger.Named("accessibility.native")
29+
pkgLoggerMu.Unlock()
30+
}
31+
32+
func currentLogger() *zap.Logger {
33+
pkgLoggerMu.RLock()
34+
defer pkgLoggerMu.RUnlock()
35+
36+
return pkgLogger
37+
}

internal/adapter/eventtap/linux/evdev_scroll_cgo.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,17 @@ var (
3838
var uinputScrollMu sync.Mutex
3939

4040
func initUinputScroll() error {
41-
var fd C.int
42-
if C.neru_uinput_create_scroll(&fd) == 0 {
43-
return fmt.Errorf("%w", errUinputScrollUnavailable)
41+
var deviceFd C.int
42+
43+
created, errno := C.neru_uinput_create_scroll(&deviceFd)
44+
if created == 0 {
45+
// errno is the /dev/uinput open failure, which is what a user has to
46+
// act on: "permission denied" means a udev rule or group, "no such
47+
// file" means the uinput module is not loaded.
48+
return fmt.Errorf("%w: /dev/uinput: %w", errUinputScrollUnavailable, errno)
4449
}
45-
uinputScrollFd = int(fd)
50+
51+
uinputScrollFd = int(deviceFd)
4652

4753
return nil
4854
}

internal/adapter/platform/capability_contract_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ func TestCapabilities_ProbeCoverageIsDocumented(t *testing.T) {
263263
// device) or has only a mutating entry point, so it is checked by that
264264
// subsystem's own tests instead of here.
265265
uncovered := map[ports.CapabilityKey]string{
266+
ports.CapabilityScroll: "injects a real scroll into the focused app",
266267
ports.CapabilityAccessibility: "needs an AX client fixture; covered by internal/adapter/accessibility",
267268
ports.CapabilityOverlay: "needs a live overlay manager; covered by internal/adapter/overlay",
268269
ports.CapabilityNotifications: "only entry point displays UI to the user",

internal/adapter/platform/linux/evdev.c

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,33 +95,45 @@ int neru_evdev_get_pressed_keys(int fd, unsigned int *out_keys, int max_keys) {
9595
return count;
9696
}
9797

98+
/* Close fd without disturbing the errno the caller is about to report. */
99+
static void neru_uinput_fail(int fd) {
100+
int saved = errno;
101+
close(fd);
102+
errno = saved;
103+
}
104+
105+
/* On failure errno describes the /dev/uinput open (or the ioctl that
106+
* refused), so the Go side can tell "permission denied" from "no such
107+
* device" instead of reporting a bare "unavailable". */
98108
int neru_uinput_create_scroll(int *out_fd) {
99109
int fd = open("/dev/uinput", O_RDWR);
100110
if (fd < 0) {
111+
int open_errno = errno;
101112
fd = open("/dev/input/uinput", O_RDWR);
102-
}
103-
if (fd < 0) {
104-
return 0;
113+
if (fd < 0) {
114+
errno = open_errno;
115+
return 0;
116+
}
105117
}
106118

107119
if (ioctl(fd, UI_SET_EVBIT, EV_REL) < 0) {
108-
close(fd);
120+
neru_uinput_fail(fd);
109121
return 0;
110122
}
111123
if (ioctl(fd, UI_SET_RELBIT, REL_WHEEL) < 0) {
112-
close(fd);
124+
neru_uinput_fail(fd);
113125
return 0;
114126
}
115127
if (ioctl(fd, UI_SET_RELBIT, REL_HWHEEL) < 0) {
116-
close(fd);
128+
neru_uinput_fail(fd);
117129
return 0;
118130
}
119131
if (ioctl(fd, UI_SET_RELBIT, REL_WHEEL_HI_RES) < 0) {
120-
close(fd);
132+
neru_uinput_fail(fd);
121133
return 0;
122134
}
123135
if (ioctl(fd, UI_SET_RELBIT, REL_HWHEEL_HI_RES) < 0) {
124-
close(fd);
136+
neru_uinput_fail(fd);
125137
return 0;
126138
}
127139

@@ -132,18 +144,32 @@ int neru_uinput_create_scroll(int *out_fd) {
132144
usetup.id.product = 0x5678;
133145
strcpy(usetup.name, "neru-scroll");
134146
if (ioctl(fd, UI_DEV_SETUP, &usetup) < 0) {
135-
close(fd);
147+
neru_uinput_fail(fd);
136148
return 0;
137149
}
138150
if (ioctl(fd, UI_DEV_CREATE) < 0) {
139-
close(fd);
151+
neru_uinput_fail(fd);
140152
return 0;
141153
}
142154

143155
*out_fd = fd;
144156
return 1;
145157
}
146158

159+
/* Create the scroll wheel the way the scroll path does, then tear it down
160+
* again. Opening the node alone is not the question `neru doctor` asks: the
161+
* UI_SET_* and UI_DEV_CREATE ioctls can refuse on a node that opened. On
162+
* failure errno is the same one neru_uinput_create_scroll leaves. */
163+
int neru_uinput_probe_scroll(void) {
164+
int fd;
165+
if (!neru_uinput_create_scroll(&fd)) {
166+
return 0;
167+
}
168+
ioctl(fd, UI_DEV_DESTROY);
169+
close(fd);
170+
return 1;
171+
}
172+
147173
int neru_uinput_scroll(int fd, int axis, int value) {
148174
struct input_event ev;
149175
memset(&ev, 0, sizeof(ev));

internal/adapter/platform/linux/evdev.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ int neru_evdev_get_bustype(int fd);
1414
ssize_t neru_evdev_read_event(int fd, struct input_event *event);
1515
int neru_evdev_get_pressed_keys(int fd, unsigned int *out_keys, int max_keys);
1616
int neru_uinput_create_scroll(int *out_fd);
17+
int neru_uinput_probe_scroll(void);
1718
int neru_uinput_scroll(int fd, int axis, int value);
1819
int neru_uinput_scroll_batch(int fd, int axis, int *values, int count);
1920
int neru_uinput_create_keyboard(int *out_fd);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build linux && cgo
2+
3+
package linux
4+
5+
/*
6+
#include "evdev.h"
7+
*/
8+
import "C"
9+
10+
import "fmt"
11+
12+
// uinputScrollDeviceError reports why the uinput scroll wheel cannot be
13+
// created, or nil when it can. It builds and destroys the same device the
14+
// scroll path uses, so a node that opens but refuses an ioctl is reported
15+
// too. The errno is the part a user acts on: "permission denied" means a
16+
// udev rule or group, "no such file" means the uinput module is not loaded.
17+
func uinputScrollDeviceError() error {
18+
created, errno := C.neru_uinput_probe_scroll()
19+
if created == 0 {
20+
return fmt.Errorf("%s: %w", uinputDevicePath, errno)
21+
}
22+
23+
return nil
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//go:build linux && !cgo
2+
3+
package linux
4+
5+
import "github.com/y3owk1n/neru/internal/derrors"
6+
7+
// uinputScrollDeviceError reports that the uinput scroll wheel is compiled
8+
// out: the device is created through cgo.
9+
func uinputScrollDeviceError() error {
10+
return derrors.New(
11+
derrors.CodeNotSupported,
12+
"uinput scroll device unavailable: this binary was built with CGO_ENABLED=0",
13+
)
14+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//go:build linux
2+
3+
package linux
4+
5+
import (
6+
"os"
7+
"strings"
8+
"testing"
9+
10+
"github.com/y3owk1n/neru/internal/ports"
11+
)
12+
13+
// TestScrollCapability_UinputUnwritableNamesTheFix pins what `neru doctor`
14+
// prints when the uinput wheel cannot be opened: a downgrade rather than a
15+
// green row, carrying the open error and the device to make writable.
16+
func TestScrollCapability_UinputUnwritableNamesTheFix(t *testing.T) {
17+
declared := ports.FeatureCapability{Status: ports.FeatureStatusSupported, Detail: "declared"}
18+
19+
tests := []struct {
20+
name string
21+
uinputErr error
22+
wantStatus ports.FeatureStatus
23+
wantDetail []string
24+
}{
25+
{
26+
name: "uinput writable keeps the declared capability",
27+
uinputErr: nil,
28+
wantStatus: ports.FeatureStatusSupported,
29+
wantDetail: []string{"declared"},
30+
},
31+
{
32+
name: "uinput unwritable downgrades with the reason and the device",
33+
uinputErr: &os.PathError{Op: "open", Path: uinputDevicePath, Err: os.ErrPermission},
34+
wantStatus: ports.FeatureStatusStub,
35+
wantDetail: []string{"permission denied", uinputDevicePath, "virtual pointer"},
36+
},
37+
}
38+
39+
for _, testCase := range tests {
40+
t.Run(testCase.name, func(t *testing.T) {
41+
got := scrollCapability(declared, testCase.uinputErr)
42+
43+
if got.Status != testCase.wantStatus {
44+
t.Fatalf("Status = %q, want %q", got.Status, testCase.wantStatus)
45+
}
46+
47+
for _, want := range testCase.wantDetail {
48+
if !strings.Contains(got.Detail, want) {
49+
t.Errorf("Detail %q does not mention %q", got.Detail, want)
50+
}
51+
}
52+
})
53+
}
54+
}

0 commit comments

Comments
 (0)