diff --git a/.github/workflows/server_test.yml b/.github/workflows/server_test.yml index fb0cccf16..120009066 100644 --- a/.github/workflows/server_test.yml +++ b/.github/workflows/server_test.yml @@ -70,6 +70,8 @@ jobs: libxfixes-dev libxcvt-dev pkg-config + xvfb + xauth version: ${{ runner.os }}-ubuntu-24.04 - name: Install system dependencies @@ -84,7 +86,7 @@ jobs: libxtst-dev \ libxfixes-dev \ libxcvt-dev \ - pkg-config + pkg-config xvfb xauth - name: Cache Go modules uses: actions/cache@v4 @@ -98,3 +100,9 @@ jobs: - name: Run tests working-directory: server run: go test ./... -v + + - name: Test XTest scroll fallback on an isolated display + working-directory: server + run: | + xvfb-run -a -s "-screen 0 1280x720x24 -nolisten tcp" \ + sh -c 'NEKO_XORG_TEST_DISPLAY="$DISPLAY" go test -race ./internal/desktop -run TestScrollXorgIntegration -count=1 -v' diff --git a/server/internal/desktop/scroll_xorg_test.go b/server/internal/desktop/scroll_xorg_test.go new file mode 100644 index 000000000..31196f561 --- /dev/null +++ b/server/internal/desktop/scroll_xorg_test.go @@ -0,0 +1,303 @@ +package desktop + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/pkg/xinput" + "github.com/m1k1o/neko/server/pkg/xorg" +) + +type scrollButtonEvent struct { + button uint32 + state uint32 +} + +type scrollObserver struct { + stdin io.WriteCloser + lines <-chan string +} + +func (observer *scrollObserver) readLine(t *testing.T) string { + t.Helper() + select { + case line, ok := <-observer.lines: + if !ok { + t.Fatal("Xorg scroll observer exited before replying") + } + return line + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Xorg scroll observer") + return "" + } +} + +func (observer *scrollObserver) collect(t *testing.T) []scrollButtonEvent { + t.Helper() + if _, err := io.WriteString(observer.stdin, "collect\n"); err != nil { + t.Fatalf("request scroll events: %v", err) + } + var events []scrollButtonEvent + for { + line := observer.readLine(t) + if line == "DONE" { + return events + } + var event scrollButtonEvent + if n, err := fmt.Sscanf(line, "BUTTON %d %d", &event.button, &event.state); err != nil || n != 2 { + t.Fatalf("invalid observer reply %q: %v", line, err) + } + events = append(events, event) + } +} + +func startScrollObserver(t *testing.T, display string) *scrollObserver { + t.Helper() + flags, err := exec.Command("pkg-config", "--cflags", "--libs", "x11").CombinedOutput() + if err != nil { + t.Fatalf("find X11 development files for observer: %v\n%s", err, flags) + } + compiler := strings.Fields(os.Getenv("CC")) + if len(compiler) == 0 { + compiler = []string{"cc"} + } + binary := filepath.Join(t.TempDir(), "scroll_observer") + args := append(compiler[1:], "-std=c99", "-Wall", "-Wextra", "-Werror", "testdata/scroll_observer.c", "-o", binary) + args = append(args, strings.Fields(string(flags))...) + if output, err := exec.Command(compiler[0], args...).CombinedOutput(); err != nil { + t.Fatalf("compile scroll observer: %v\n%s", err, output) + } + + ctx, cancel := context.WithCancel(context.Background()) + command := exec.CommandContext(ctx, binary, display) + command.Stderr = os.Stderr + stdin, err := command.StdinPipe() + if err != nil { + cancel() + t.Fatal(err) + } + stdout, err := command.StdoutPipe() + if err != nil { + cancel() + t.Fatal(err) + } + if err := command.Start(); err != nil { + cancel() + t.Fatalf("start scroll observer: %v", err) + } + t.Cleanup(func() { + _ = stdin.Close() + cancel() + _ = command.Wait() + }) + lines := make(chan string) + go func() { + defer close(lines) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + select { + case lines <- scanner.Text(): + case <-ctx.Done(): + return + } + } + }() + observer := &scrollObserver{stdin: stdin, lines: lines} + if line := observer.readLine(t); line != "READY" { + t.Fatalf("unexpected observer startup reply: %q", line) + } + return observer +} + +type successfulScrollDriver struct { + xinput.Driver + calls [][2]int32 +} + +func (driver *successfulScrollDriver) Scroll(deltaX, deltaY int32) error { + driver.calls = append(driver.calls, [2]int32{deltaX, deltaY}) + return nil +} + +// TestScrollXorgIntegration injects real input into an explicitly selected, +// disposable X server. It must never default to the user's desktop display. +// Run with NEKO_XORG_TEST_DISPLAY=:99 go test ./internal/desktop -run TestScrollXorgIntegration. +// The display needs XTEST; cc, pkg-config and X11 development files are needed +// to build the observer. Ordinary go test runs skip this integration test. +func TestScrollXorgIntegration(t *testing.T) { + display := os.Getenv("NEKO_XORG_TEST_DISPLAY") + if display == "" { + t.Skip("set NEKO_XORG_TEST_DISPLAY to an isolated X server to test actual scroll events") + } + observer := startScrollObserver(t, display) + if xorg.DisplayOpen(display) { + t.Fatalf("open test display %q", display) + } + t.Cleanup(xorg.DisplayClose) + t.Cleanup(func() { + xorg.ResetKeys() + xorg.SetKeyboardModifier(xorg.KbdModControl, false) + }) + + manager := &DesktopManagerCtx{ + config: &config.Desktop{UseInputDriver: true}, + input: xinput.NewDummy(), // Scroll returns a deterministic driver error. + logger: zerolog.Nop(), + } + reset := func(t *testing.T) { + t.Helper() + xorg.ResetKeys() + xorg.SetKeyboardModifier(xorg.KbdModControl, false) + manager.config.UseInputDriver = true + manager.input = xinput.NewDummy() + observer.collect(t) + } + expect := func(t *testing.T, control bool, buttons ...uint32) { + t.Helper() + events := observer.collect(t) + if len(events) != len(buttons) { + t.Fatalf("got wheel events %v; want buttons %v (Control=%t)", events, buttons, control) + } + for i, event := range events { + if event.button != buttons[i] || (event.state&uint32(xorg.KbdModControl) != 0) != control { + t.Fatalf("event %d = %+v; want button %d, Control=%t", i, event, buttons[i], control) + } + } + } + assertControlReleased := func(t *testing.T) { + t.Helper() + if xorg.GetKeyboardModifiers()&xorg.KbdModControl != 0 { + t.Fatal("temporary Control modifier remains held after scroll") + } + } + + t.Run("fallback converts one notch", func(t *testing.T) { + reset(t) + manager.Scroll(0, 120, false) + expect(t, false, 5) + }) + t.Run("zero and fractional deltas accumulate", func(t *testing.T) { + reset(t) + manager.Scroll(0, 0, false) + manager.Scroll(0, 60, false) + expect(t, false) + manager.Scroll(0, 60, false) + expect(t, false, 5) + }) + t.Run("sign and axes are independent", func(t *testing.T) { + reset(t) + manager.Scroll(130, -250, false) + expect(t, false, 4, 4, 7) + manager.Scroll(110, -110, false) + expect(t, false, 4, 7) + manager.Scroll(-120, 120, false) + expect(t, false, 5, 6) + }) + t.Run("direction reversal cancels pending units", func(t *testing.T) { + reset(t) + manager.Scroll(0, 90, false) + manager.Scroll(0, -120, false) + expect(t, false) + manager.Scroll(0, -90, false) + expect(t, false, 4) + }) + t.Run("coalesced gesture retains remainder", func(t *testing.T) { + reset(t) + manager.Scroll(0, 1500, false) + expect(t, false, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5) + manager.Scroll(0, 60, false) + expect(t, false, 5) + }) + t.Run("explicit Control keeps separate residual", func(t *testing.T) { + reset(t) + manager.Scroll(0, 110, false) + manager.Scroll(0, 30, true) + expect(t, false) + assertControlReleased(t) + manager.Scroll(0, 10, false) + expect(t, false, 5) + manager.Scroll(0, 90, true) + expect(t, true, 5) + assertControlReleased(t) + }) + t.Run("physical Control keeps separate residual", func(t *testing.T) { + reset(t) + manager.Scroll(0, 110, false) + const controlLeft = 0xffe3 // XK_Control_L, sent separately by legacy clients. + if err := manager.KeyDown(controlLeft); err != nil { + t.Fatal(err) + } + manager.Scroll(0, 30, false) + expect(t, false) + if err := manager.KeyUp(controlLeft); err != nil { + t.Fatal(err) + } + manager.Scroll(0, 10, false) + expect(t, false, 5) + if err := manager.KeyDown(controlLeft); err != nil { + t.Fatal(err) + } + manager.Scroll(0, 90, false) + expect(t, true, 5) + if err := manager.KeyUp(controlLeft); err != nil { + t.Fatal(err) + } + assertControlReleased(t) + }) + t.Run("reset discards both residuals", func(t *testing.T) { + reset(t) + manager.Scroll(0, 100, false) + manager.Scroll(0, 100, true) + manager.ResetKeys() + manager.Scroll(0, 20, false) + manager.Scroll(0, 20, true) + expect(t, false) + manager.Scroll(0, 100, false) + expect(t, false, 5) + manager.Scroll(0, 100, true) + expect(t, true, 5) + assertControlReleased(t) + }) + t.Run("driver success preserves raw scroll units", func(t *testing.T) { + reset(t) + driver := &successfulScrollDriver{Driver: xinput.NewDummy()} + manager.input = driver + manager.Scroll(-240, 1500, false) + manager.Scroll(120, -120, true) + expect(t, false) + assertControlReleased(t) + want := [][2]int32{{-240, 1500}, {120, -120}} + if len(driver.calls) != len(want) { + t.Fatalf("driver calls = %v; want %v", driver.calls, want) + } + for i := range want { + if driver.calls[i] != want[i] { + t.Fatalf("driver call %d = %v; want %v", i, driver.calls[i], want[i]) + } + } + // Successful driver deltas must not seed the fallback's accumulator. + manager.input = xinput.NewDummy() + manager.Scroll(0, 60, false) + expect(t, false) + }) + t.Run("driver disabled preserves click-count semantics", func(t *testing.T) { + reset(t) + manager.config.UseInputDriver = false + manager.Scroll(-1, 2, false) + expect(t, false, 5, 5, 6) + manager.Scroll(0, -1, true) + expect(t, true, 4) + assertControlReleased(t) + }) +} diff --git a/server/internal/desktop/testdata/scroll_observer.c b/server/internal/desktop/testdata/scroll_observer.c new file mode 100644 index 000000000..379d370ce --- /dev/null +++ b/server/internal/desktop/testdata/scroll_observer.c @@ -0,0 +1,55 @@ +#include +#include +#include + +/* A dedicated window on the opt-in test display records real XTest events. + * Each collection follows the producer's XSync and our own round trip, so + * tests do not need a timing sleep to wait for input delivery. */ +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: scroll_observer DISPLAY\n"); + return 1; + } + + Display *display = XOpenDisplay(argv[1]); + if (display == NULL) { + fprintf(stderr, "cannot open test display %s\n", argv[1]); + return 1; + } + + int screen = DefaultScreen(display); + Window window = XCreateSimpleWindow(display, RootWindow(display, screen), + 0, 0, 640, 480, 0, + BlackPixel(display, screen), + WhitePixel(display, screen)); + XSelectInput(display, window, ButtonPressMask); + XMapRaised(display, window); + XSetInputFocus(display, window, RevertToPointerRoot, CurrentTime); + XWarpPointer(display, None, window, 0, 0, 0, 0, 32, 32); + XSync(display, False); + puts("READY"); + fflush(stdout); + + char command[32]; + while (fgets(command, sizeof(command), stdin) != NULL) { + if (strcmp(command, "collect\n") != 0) { + fprintf(stderr, "unknown observer command\n"); + XCloseDisplay(display); + return 1; + } + XSync(display, False); + while (XPending(display)) { + XEvent event; + XNextEvent(display, &event); + if (event.type == ButtonPress) { + printf("BUTTON %u %u\n", event.xbutton.button, event.xbutton.state); + } + } + puts("DONE"); + fflush(stdout); + } + + XDestroyWindow(display, window); + XCloseDisplay(display); + return 0; +} diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index 907f1abc1..3e56ee158 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -28,7 +28,8 @@ func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { } if err := manager.input.Scroll(int32(deltaX), int32(deltaY)); err != nil { manager.logger.Warn().Err(err).Msg("xinput scroll failed, falling back to XTest") - xorg.Scroll(deltaX, deltaY, false) + // the driver's deltas are scroll units, not wheel clicks + xorg.ScrollUnits(deltaX, deltaY, controlKey) } } else { // XTest fallback — handles controlKey atomically under a single X11 lock diff --git a/server/pkg/xorg/scroll_units.go b/server/pkg/xorg/scroll_units.go new file mode 100644 index 000000000..3a9dfc50e --- /dev/null +++ b/server/pkg/xorg/scroll_units.go @@ -0,0 +1,73 @@ +package xorg + +// scrollNotchUnits is the number of scroll units that make up one wheel notch. +// The xf86-input-neko driver registers its scroll valuators with this +// increment (SCROLL_INCREMENT), so a delta bound for the driver means the same +// thing when the XTest fallback has to replay it. See pkg/xinput. +const scrollNotchUnits = 120 + +// Plain and Control-held scrolling accumulate separately. Sub-notch motion +// left over from a page scroll must not discharge as a notch while Control is +// held, because the browser applies that as a zoom step rather than scrolling. +// Both are guarded by mu, like the debounce maps in xorg.go. +var ( + scrollResidual scrollAccumulator + scrollResidualCtrl scrollAccumulator +) + +// scrollResidualFor returns the accumulator owning scrolls with or without +// Control held. +func scrollResidualFor(controlKey bool) *scrollAccumulator { + if controlKey { + return &scrollResidualCtrl + } + return &scrollResidual +} + +// resetScrollResiduals discards sub-notch motion pending on either accumulator. +func resetScrollResiduals() { + scrollResidual.reset() + scrollResidualCtrl.reset() +} + +// scrollAccumulator turns scroll deltas into whole wheel notches, carrying the +// sub-notch remainder between calls. XTest can only emit discrete wheel button +// clicks, so a delta has to be divided into notches before it is replayed, and +// carrying the remainder keeps slow scrolling from being rounded away. +type scrollAccumulator struct { + x, y int // pending units, always within (-scrollNotchUnits, scrollNotchUnits) +} + +// add accumulates a delta in scroll units and returns the whole notches now +// due on each axis. Notches are truncated toward zero, so the remainder keeps +// the sign of the pending motion and a reversal cancels it before emitting a +// notch in the new direction. +func (a *scrollAccumulator) add(deltaX, deltaY int) (notchesX, notchesY int) { + notchesX, a.x = accumulateScrollAxis(a.x, deltaX) + notchesY, a.y = accumulateScrollAxis(a.y, deltaY) + return notchesX, notchesY +} + +func accumulateScrollAxis(residual, delta int) (notches, remainder int) { + // Split before adding so a valid delta near an int limit cannot overflow + // when a previous call left a sub-notch remainder. + notches = delta / scrollNotchUnits + remainder = residual + delta%scrollNotchUnits + notches += remainder / scrollNotchUnits + remainder %= scrollNotchUnits + + // Keep truncation toward zero when the delta reverses the residual. + if notches > 0 && remainder < 0 { + notches-- + remainder += scrollNotchUnits + } else if notches < 0 && remainder > 0 { + notches++ + remainder -= scrollNotchUnits + } + return notches, remainder +} + +// reset discards any pending sub-notch motion. +func (a *scrollAccumulator) reset() { + a.x, a.y = 0, 0 +} diff --git a/server/pkg/xorg/scroll_units_test.go b/server/pkg/xorg/scroll_units_test.go new file mode 100644 index 000000000..9df1a4f13 --- /dev/null +++ b/server/pkg/xorg/scroll_units_test.go @@ -0,0 +1,127 @@ +package xorg + +import ( + "math/big" + "testing" +) + +func TestScrollAccumulatorAdd(t *testing.T) { + // each step feeds delta on the vertical axis and checks the notches + // returned and the residual left behind for the next call + type step struct { + delta, notches, residual int + } + + tests := []struct { + name string + steps []step + }{ + {"whole notch", []step{{120, 1, 0}}}, + {"half notches accumulate across calls", []step{{60, 0, 60}, {60, 1, 0}}}, + {"negative delta truncates toward zero", []step{{-180, -1, -60}}}, + {"reversal cancels the residual first", []step{{90, 0, 90}, {-120, 0, -30}, {-90, -1, 0}}}, + {"coalesced gesture emits one click per notch", []step{{1500, 12, 60}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var acc scrollAccumulator + for i, s := range tt.steps { + notchesX, notchesY := acc.add(0, s.delta) + if notchesX != 0 || acc.x != 0 { + t.Fatalf("step %d: horizontal axis moved: %d notches, residual %d", i, notchesX, acc.x) + } + if notchesY != s.notches || acc.y != s.residual { + t.Fatalf("step %d: add(0, %d) = %d notches, residual %d; want %d notches, residual %d", + i, s.delta, notchesY, acc.y, s.notches, s.residual) + } + } + }) + } +} + +func TestScrollAccumulatorAxesAreIndependent(t *testing.T) { + var acc scrollAccumulator + + notchesX, notchesY := acc.add(130, -250) + if notchesX != 1 || notchesY != -2 { + t.Fatalf("add(130, -250) = (%d, %d), want (1, -2)", notchesX, notchesY) + } + if acc.x != 10 || acc.y != -10 { + t.Fatalf("residual = (%d, %d), want (10, -10)", acc.x, acc.y) + } +} + +func TestScrollAccumulatorReset(t *testing.T) { + var acc scrollAccumulator + acc.add(100, -100) + acc.reset() + + if acc.x != 0 || acc.y != 0 { + t.Fatalf("residual after reset = (%d, %d), want (0, 0)", acc.x, acc.y) + } + + // the discarded motion must not contribute to the next notch + if notchesX, notchesY := acc.add(20, -20); notchesX != 0 || notchesY != 0 { + t.Fatalf("add(20, -20) after reset = (%d, %d), want (0, 0)", notchesX, notchesY) + } +} + +func TestScrollResidualsDoNotCrossModifiers(t *testing.T) { + resetScrollResiduals() + defer resetScrollResiduals() + + // a page scroll that has not yet reached a whole notch + if _, notchesY := scrollResidualFor(false).add(0, 110); notchesY != 0 { + t.Fatalf("plain add(0, 110) = %d notches, want 0", notchesY) + } + + // a Control-held scroll must not discharge it, which the browser would + // apply as a zoom step instead of scrolling the page + if _, notchesY := scrollResidualFor(true).add(0, 30); notchesY != 0 { + t.Fatalf("control add(0, 30) = %d notches, want 0", notchesY) + } + + // the page scroll is still pending and completes on its own + if _, notchesY := scrollResidualFor(false).add(0, 10); notchesY != 1 { + t.Fatalf("plain add(0, 10) = %d notches, want 1", notchesY) + } +} + +func TestResetScrollResidualsClearsBoth(t *testing.T) { + resetScrollResiduals() + defer resetScrollResiduals() + + scrollResidualFor(false).add(0, 100) + scrollResidualFor(true).add(0, 100) + resetScrollResiduals() + + if _, notchesY := scrollResidualFor(false).add(0, 20); notchesY != 0 { + t.Fatalf("plain add(0, 20) after reset = %d notches, want 0", notchesY) + } + if _, notchesY := scrollResidualFor(true).add(0, 20); notchesY != 0 { + t.Fatalf("control add(0, 20) after reset = %d notches, want 0", notchesY) + } +} + +func TestScrollAccumulatorIntegerLimits(t *testing.T) { + maxInt := int(^uint(0) >> 1) + minInt := -maxInt - 1 + deltas := []int{minInt, minInt + 119, -121, -120, -119, -1, 0, 1, 119, 120, 121, maxInt - 119, maxInt} + for residual := -119; residual <= 119; residual++ { + for _, delta := range deltas { + var total, divisor, quotient, remainder big.Int + total.SetInt64(int64(delta)) + total.Add(&total, big.NewInt(int64(residual))) + divisor.SetInt64(scrollNotchUnits) + quotient.QuoRem(&total, &divisor, &remainder) + + acc := scrollAccumulator{x: residual, y: residual} + x, y := acc.add(delta, delta) + if x != int(quotient.Int64()) || y != x || acc.x != int(remainder.Int64()) || acc.y != acc.x { + t.Fatalf("residual %d + delta %d = (%d, %d) remainder (%d, %d); want %s remainder %s", + residual, delta, x, y, acc.x, acc.y, quotient.String(), remainder.String()) + } + } + } +} diff --git a/server/pkg/xorg/xorg.go b/server/pkg/xorg/xorg.go index 8d5ee050d..c3e058988 100644 --- a/server/pkg/xorg/xorg.go +++ b/server/pkg/xorg/xorg.go @@ -101,6 +101,26 @@ func Scroll(deltaX, deltaY int, controlKey bool) { C.XScroll(C.int(deltaX), C.int(deltaY)) } +// ScrollUnits scrolls by a delta expressed in the xf86-input-neko driver's +// scroll units rather than in wheel clicks, for the XTest fallback taken when +// the driver is enabled but unreachable. XTest can only click whole notches, +// so the sub-notch remainder is carried to the next call instead of being +// replayed once per unit. Its caller latches the Control modifier around the +// driver attempt. Legacy clients can also hold Control through key events, +// so select the accumulator using the effective X server modifier state. +func ScrollUnits(deltaX, deltaY int, controlKey bool) { + mu.Lock() + defer mu.Unlock() + + controlKey = controlKey || C.XGetKeyboardModifiers()&C.ControlMask != 0 + notchesX, notchesY := scrollResidualFor(controlKey).add(deltaX, deltaY) + if notchesX == 0 && notchesY == 0 { + return + } + + C.XScroll(C.int(notchesX), C.int(notchesY)) +} + func ButtonDown(code uint32) error { mu.Lock() defer mu.Unlock() @@ -170,6 +190,8 @@ func ResetKeys() { C.XKey(C.KeySym(code), C.int(0)) delete(debounce_key, code) } + + resetScrollResiduals() } func CheckKeys(duration time.Duration) {