From e10f04343a3007629e39b6fdabfbd56c85f1708b Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 30 Mar 2026 16:34:47 -0400 Subject: [PATCH 1/7] feat: add CDP-based pixel-precise scroll support When NEKO_DESKTOP_CDP_SCROLL_URL is set, scroll events are dispatched via Chrome DevTools Protocol (Input.dispatchMouseEvent mouseWheel) instead of X11 XTestFakeButtonEvent. This enables pixel-precise, smooth scrolling for trackpads and high-resolution mice. - Add cdpscroll package with auto-discovery of browser WebSocket URL - Wire CDP scroll into DesktopManager with X11 fallback - Add new scroll payload format (length=5) with controlKey support - Existing legacy scroll format (length=4) continues to work Made-with: Cursor --- server/internal/config/desktop.go | 8 + server/internal/desktop/manager.go | 13 ++ server/internal/desktop/xorg.go | 13 ++ server/internal/webrtc/legacyhandler.go | 42 +++-- server/pkg/cdpscroll/cdpscroll.go | 230 ++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 server/pkg/cdpscroll/cdpscroll.go diff --git a/server/internal/config/desktop.go b/server/internal/config/desktop.go index 8331035e7..e891cfdb4 100644 --- a/server/internal/config/desktop.go +++ b/server/internal/config/desktop.go @@ -23,6 +23,8 @@ type Desktop struct { Unminimize bool UploadDrop bool FileChooserDialog bool + + CDPScrollURL string } func (Desktop) Init(cmd *cobra.Command) error { @@ -61,6 +63,11 @@ func (Desktop) Init(cmd *cobra.Command) error { return err } + cmd.PersistentFlags().String("desktop.cdp_scroll_url", "", "Chromium devtools HTTP base URL for pixel-precise scroll via CDP (e.g. http://127.0.0.1:9223)") + if err := viper.BindPFlag("desktop.cdp_scroll_url", cmd.PersistentFlags().Lookup("desktop.cdp_scroll_url")); err != nil { + return err + } + return nil } @@ -107,6 +114,7 @@ func (s *Desktop) Set() { s.Unminimize = viper.GetBool("desktop.unminimize") s.UploadDrop = viper.GetBool("desktop.upload_drop") s.FileChooserDialog = viper.GetBool("desktop.file_chooser_dialog") + s.CDPScrollURL = viper.GetString("desktop.cdp_scroll_url") } func (s *Desktop) SetV2() { diff --git a/server/internal/desktop/manager.go b/server/internal/desktop/manager.go index aca5fe1a5..dde890bbf 100644 --- a/server/internal/desktop/manager.go +++ b/server/internal/desktop/manager.go @@ -11,6 +11,7 @@ import ( "github.com/rs/zerolog/log" "github.com/m1k1o/neko/server/internal/config" + "github.com/m1k1o/neko/server/pkg/cdpscroll" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/xevent" "github.com/m1k1o/neko/server/pkg/xinput" @@ -27,6 +28,7 @@ type DesktopManagerCtx struct { config *config.Desktop screenSize types.ScreenSize // cached screen size input xinput.Driver + cdpScroll *cdpscroll.Client // Clipboard process holding the most recent clipboard data. // It must remain running to allow pasting clipboard data. @@ -42,6 +44,12 @@ func New(config *config.Desktop) *DesktopManagerCtx { input = xinput.NewDummy() } + var cdp *cdpscroll.Client + if config.CDPScrollURL != "" { + cdp = cdpscroll.New(config.CDPScrollURL) + log.Info().Str("url", config.CDPScrollURL).Msg("CDP scroll enabled") + } + return &DesktopManagerCtx{ logger: log.With().Str("module", "desktop").Logger(), shutdown: make(chan struct{}), @@ -49,6 +57,7 @@ func New(config *config.Desktop) *DesktopManagerCtx { config: config, screenSize: config.ScreenSize, input: input, + cdpScroll: cdp, } } @@ -139,6 +148,10 @@ func (manager *DesktopManagerCtx) Shutdown() error { close(manager.shutdown) + if manager.cdpScroll != nil { + manager.cdpScroll.Close() + } + manager.replaceClipboardCommand(nil) manager.wg.Wait() diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index 4fec40f05..039a428ce 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -19,6 +19,19 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) { } func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { + if manager.cdpScroll != nil { + curX, curY := xorg.GetCursorPosition() + modifiers := 0 + if controlKey { + modifiers = 2 // CDP Ctrl modifier bitmask + } + err := manager.cdpScroll.DispatchScroll(curX, curY, float64(deltaX), float64(deltaY), modifiers) + if err != nil { + manager.logger.Warn().Err(err).Msg("CDP scroll failed, falling back to X11") + xorg.Scroll(deltaX, deltaY, controlKey) + } + return + } xorg.Scroll(deltaX, deltaY, controlKey) } diff --git a/server/internal/webrtc/legacyhandler.go b/server/internal/webrtc/legacyhandler.go index 1185e6e07..6b94f9dda 100644 --- a/server/internal/webrtc/legacyhandler.go +++ b/server/internal/webrtc/legacyhandler.go @@ -3,7 +3,6 @@ package webrtc import ( "bytes" "encoding/binary" - "strconv" "github.com/m1k1o/neko/server/pkg/types" @@ -35,6 +34,13 @@ type PayloadScroll struct { Y int16 } +type PayloadScrollWithCtrl struct { + PayloadHeader + DeltaX int16 + DeltaY int16 + ControlKey uint8 +} + type PayloadKey struct { PayloadHeader Key uint64 // TODO: uint32 @@ -72,18 +78,32 @@ func (manager *WebRTCManagerCtx) handleLegacy( manager.desktop.Move(int(payload.X), int(payload.Y)) case OP_SCROLL: - payload := &PayloadScroll{} - if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { - return err - } + if header.Length == 5 { + payload := &PayloadScrollWithCtrl{} + if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { + return err + } + + logger.Trace(). + Int16("deltaX", payload.DeltaX). + Int16("deltaY", payload.DeltaY). + Bool("controlKey", payload.ControlKey != 0). + Msg("scroll") + + manager.desktop.Scroll(int(payload.DeltaX), int(payload.DeltaY), payload.ControlKey != 0) + } else { + payload := &PayloadScroll{} + if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { + return err + } - logger. - Trace(). - Str("x", strconv.Itoa(int(payload.X))). - Str("y", strconv.Itoa(int(payload.Y))). - Msg("scroll") + logger.Trace(). + Int16("x", payload.X). + Int16("y", payload.Y). + Msg("scroll (legacy)") - manager.desktop.Scroll(int(payload.X), int(payload.Y), false) + manager.desktop.Scroll(int(payload.X), int(payload.Y), false) + } case OP_KEY_DOWN: payload := &PayloadKey{} if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { diff --git a/server/pkg/cdpscroll/cdpscroll.go b/server/pkg/cdpscroll/cdpscroll.go new file mode 100644 index 000000000..ce4e242b4 --- /dev/null +++ b/server/pkg/cdpscroll/cdpscroll.go @@ -0,0 +1,230 @@ +package cdpscroll + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Client sends pixel-precise mouseWheel events to Chromium via CDP. +// It auto-discovers the browser WebSocket URL from the devtools HTTP endpoint. +type Client struct { + logger zerolog.Logger + baseURL string // e.g. "http://127.0.0.1:9223" + mu sync.Mutex + conn *websocket.Conn + msgID atomic.Int64 + sessionID string +} + +// New creates a CDP scroll client. baseURL is the Chromium devtools HTTP +// endpoint (e.g. "http://127.0.0.1:9223"). +func New(baseURL string) *Client { + return &Client{ + logger: log.With().Str("module", "cdpscroll").Logger(), + baseURL: baseURL, + } +} + +type cdpMessage struct { + ID int64 `json:"id"` + Method string `json:"method,omitempty"` + Params map[string]any `json:"params,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` +} + +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (c *Client) discoverWSURL(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/json/version", nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("discover CDP URL: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var version struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.Unmarshal(body, &version); err != nil { + return "", fmt.Errorf("parse /json/version: %w", err) + } + if version.WebSocketDebuggerURL == "" { + return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version") + } + return version.WebSocketDebuggerURL, nil +} + +func (c *Client) connect(ctx context.Context) error { + if c.conn != nil { + return nil + } + + wsURL, err := c.discoverWSURL(ctx) + if err != nil { + return err + } + + dialer := websocket.Dialer{ + HandshakeTimeout: 2 * time.Second, + } + conn, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) + if err != nil { + return fmt.Errorf("cdp dial: %w", err) + } + c.conn = conn + + if err := c.attachToPage(ctx); err != nil { + c.conn.Close() + c.conn = nil + return err + } + + c.logger.Info().Str("url", wsURL).Msg("CDP scroll connected") + return nil +} + +func (c *Client) send(ctx context.Context, method string, params map[string]any, sessionID string) (json.RawMessage, error) { + id := c.msgID.Add(1) + msg := cdpMessage{ + ID: id, + Method: method, + Params: params, + SessionID: sessionID, + } + + if err := c.conn.WriteJSON(msg); err != nil { + return nil, fmt.Errorf("cdp write %s: %w", method, err) + } + + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(3 * time.Second) + } + c.conn.SetReadDeadline(deadline) + + for { + var resp cdpMessage + if err := c.conn.ReadJSON(&resp); err != nil { + return nil, fmt.Errorf("cdp read %s: %w", method, err) + } + if resp.ID == id { + if resp.Error != nil { + return nil, fmt.Errorf("cdp %s error: %s", method, resp.Error.Message) + } + return resp.Result, nil + } + } +} + +func (c *Client) attachToPage(ctx context.Context) error { + result, err := c.send(ctx, "Target.getTargets", nil, "") + if err != nil { + return err + } + + var targets struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfos"` + } + if err := json.Unmarshal(result, &targets); err != nil { + return fmt.Errorf("parse targets: %w", err) + } + + var pageTargetID string + for _, t := range targets.TargetInfos { + if t.Type == "page" { + pageTargetID = t.TargetID + break + } + } + if pageTargetID == "" { + return fmt.Errorf("no page target found") + } + + result, err = c.send(ctx, "Target.attachToTarget", map[string]any{ + "targetId": pageTargetID, + "flatten": true, + }, "") + if err != nil { + return err + } + + var attach struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(result, &attach); err != nil { + return fmt.Errorf("parse attach: %w", err) + } + c.sessionID = attach.SessionID + return nil +} + +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.sessionID = "" + } +} + +// DispatchScroll sends a pixel-precise mouseWheel event via CDP. +// modifiers is a bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. +func (c *Client) DispatchScroll(x, y int, deltaX, deltaY float64, modifiers int) error { + c.mu.Lock() + defer c.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := c.connect(ctx); err != nil { + return err + } + + params := map[string]any{ + "type": "mouseWheel", + "x": x, + "y": y, + "deltaX": deltaX, + "deltaY": deltaY, + } + if modifiers != 0 { + params["modifiers"] = modifiers + } + + _, err := c.send(ctx, "Input.dispatchMouseEvent", params, c.sessionID) + if err != nil { + c.conn.Close() + c.conn = nil + c.sessionID = "" + return err + } + return nil +} From 92379fce5f0d9b72f6893996188b196311a5f9e6 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 30 Mar 2026 21:40:38 -0400 Subject: [PATCH 2/7] feat: add XI2 smooth scroll via xf86-input-neko driver Route scroll events through the xinput socket to the Xorg driver instead of using XTestFakeButtonEvent. The driver posts them as native XInput2 scroll valuator changes, giving pixel-precise smooth scrolling that Chromium handles identically to a real trackpad. Adds Scroll() method to xinput.Driver interface. DesktopManager.Scroll() uses xinput when UseInputDriver is true, falling back to XTest otherwise. Made-with: Cursor --- server/internal/desktop/xorg.go | 34 ++++++++++++++++++++++++++------- server/pkg/xinput/dummy.go | 4 ++++ server/pkg/xinput/types.go | 4 ++++ server/pkg/xinput/xinput.go | 13 +++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index 039a428ce..fd752507e 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -19,15 +19,35 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) { } func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { - if manager.cdpScroll != nil { - curX, curY := xorg.GetCursorPosition() - modifiers := 0 + if manager.config.UseInputDriver { + // Route through the xf86-input-neko driver for native XI2 smooth scroll. + // Convert pixel deltas to scroll valuator units (120 = one notch ≈ 3 lines). + // The scaling factor maps ~100 CSS pixels to one notch. + const pixelsPerNotch = 100.0 + const scrollIncrement = 120.0 + sx := int32(float64(deltaX) * scrollIncrement / pixelsPerNotch) + sy := int32(float64(deltaY) * scrollIncrement / pixelsPerNotch) + if sx == 0 && sy == 0 && (deltaX != 0 || deltaY != 0) { + // Preserve sub-pixel deltas: ensure at least 1 unit of scroll + if deltaX != 0 { + sx = 1 + if deltaX < 0 { + sx = -1 + } + } + if deltaY != 0 { + sy = 1 + if deltaY < 0 { + sy = -1 + } + } + } if controlKey { - modifiers = 2 // CDP Ctrl modifier bitmask + xorg.SetKeyboardModifier(xorg.KbdModControl, true) + defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) } - err := manager.cdpScroll.DispatchScroll(curX, curY, float64(deltaX), float64(deltaY), modifiers) - if err != nil { - manager.logger.Warn().Err(err).Msg("CDP scroll failed, falling back to X11") + if err := manager.input.Scroll(sx, sy); err != nil { + manager.logger.Warn().Err(err).Msg("xinput scroll failed, falling back to XTest") xorg.Scroll(deltaX, deltaY, controlKey) } return diff --git a/server/pkg/xinput/dummy.go b/server/pkg/xinput/dummy.go index a2d5c3449..cfea25d9d 100644 --- a/server/pkg/xinput/dummy.go +++ b/server/pkg/xinput/dummy.go @@ -29,3 +29,7 @@ func (d *dummy) TouchUpdate(touchId uint32, x, y int, pressure uint8) error { func (d *dummy) TouchEnd(touchId uint32, x, y int, pressure uint8) error { return nil } + +func (d *dummy) Scroll(deltaX, deltaY int32) error { + return nil +} diff --git a/server/pkg/xinput/types.go b/server/pkg/xinput/types.go index 7f94044d0..841d7584c 100644 --- a/server/pkg/xinput/types.go +++ b/server/pkg/xinput/types.go @@ -12,6 +12,7 @@ const ( XI_TouchBegin = 18 XI_TouchUpdate = 19 XI_TouchEnd = 20 + NEKO_SCROLL = 0x80 ) type Message struct { @@ -58,4 +59,7 @@ type Driver interface { TouchBegin(touchId uint32, x, y int, pressure uint8) error TouchUpdate(touchId uint32, x, y int, pressure uint8) error TouchEnd(touchId uint32, x, y int, pressure uint8) error + // scroll via XI2 scroll valuators in the xf86-input-neko driver. + // deltaX/deltaY are in scroll units (120 = one notch). + Scroll(deltaX, deltaY int32) error } diff --git a/server/pkg/xinput/xinput.go b/server/pkg/xinput/xinput.go index a6ce10330..efd87c78c 100644 --- a/server/pkg/xinput/xinput.go +++ b/server/pkg/xinput/xinput.go @@ -120,3 +120,16 @@ func (d *driver) TouchEnd(touchId uint32, x, y int, pressure uint8) error { _, err := d.conn.Write(msg.Pack()) return err } + +func (d *driver) Scroll(deltaX, deltaY int32) error { + d.mu.Lock() + defer d.mu.Unlock() + + msg := Message{ + _type: NEKO_SCROLL, + x: deltaX, + y: deltaY, + } + _, err := d.conn.Write(msg.Pack()) + return err +} From 1b3c88a3851dabfc2f3ea3eae28b0fdb61910ac9 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 10:05:04 -0400 Subject: [PATCH 3/7] feat: send raw pixel deltas to xinput driver for XI2.1 smooth scroll Remove the pixelsPerNotch conversion that quantized scroll events into discrete notches. The xf86-input-neko driver now has scroll valuators (axes 3/4), so raw pixel deltas produce 1:1 smooth scrolling in Chromium. Also fix XTest fallback scroll direction (button 4/5 mapping was inverted). Made-with: Cursor --- server/internal/desktop/manager.go | 1 + server/internal/desktop/xorg.go | 41 +++++-------------------- server/internal/webrtc/legacyhandler.go | 2 +- server/internal/webrtc/manager.go | 1 - server/pkg/xorg/xorg.c | 8 ++--- 5 files changed, 14 insertions(+), 39 deletions(-) diff --git a/server/internal/desktop/manager.go b/server/internal/desktop/manager.go index dde890bbf..1412b3548 100644 --- a/server/internal/desktop/manager.go +++ b/server/internal/desktop/manager.go @@ -39,6 +39,7 @@ type DesktopManagerCtx struct { func New(config *config.Desktop) *DesktopManagerCtx { var input xinput.Driver if config.UseInputDriver { + log.Info().Str("socket", config.InputSocket).Msg("using xinput driver for scroll") input = xinput.NewDriver(config.InputSocket) } else { input = xinput.NewDummy() diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index fd752507e..2912cb14a 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -19,40 +19,15 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) { } func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { - if manager.config.UseInputDriver { - // Route through the xf86-input-neko driver for native XI2 smooth scroll. - // Convert pixel deltas to scroll valuator units (120 = one notch ≈ 3 lines). - // The scaling factor maps ~100 CSS pixels to one notch. - const pixelsPerNotch = 100.0 - const scrollIncrement = 120.0 - sx := int32(float64(deltaX) * scrollIncrement / pixelsPerNotch) - sy := int32(float64(deltaY) * scrollIncrement / pixelsPerNotch) - if sx == 0 && sy == 0 && (deltaX != 0 || deltaY != 0) { - // Preserve sub-pixel deltas: ensure at least 1 unit of scroll - if deltaX != 0 { - sx = 1 - if deltaX < 0 { - sx = -1 - } - } - if deltaY != 0 { - sy = 1 - if deltaY < 0 { - sy = -1 - } - } - } - if controlKey { - xorg.SetKeyboardModifier(xorg.KbdModControl, true) - defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) - } - if err := manager.input.Scroll(sx, sy); err != nil { - manager.logger.Warn().Err(err).Msg("xinput scroll failed, falling back to XTest") - xorg.Scroll(deltaX, deltaY, controlKey) - } - return + if controlKey { + xorg.SetKeyboardModifier(xorg.KbdModControl, true) + defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) + } + + 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) } - xorg.Scroll(deltaX, deltaY, controlKey) } func (manager *DesktopManagerCtx) ButtonDown(code uint32) error { diff --git a/server/internal/webrtc/legacyhandler.go b/server/internal/webrtc/legacyhandler.go index 6b94f9dda..fd1f6e35a 100644 --- a/server/internal/webrtc/legacyhandler.go +++ b/server/internal/webrtc/legacyhandler.go @@ -100,7 +100,7 @@ func (manager *WebRTCManagerCtx) handleLegacy( logger.Trace(). Int16("x", payload.X). Int16("y", payload.Y). - Msg("scroll (legacy)") + Msg("scroll") manager.desktop.Scroll(int(payload.X), int(payload.Y), false) } diff --git a/server/internal/webrtc/manager.go b/server/internal/webrtc/manager.go index 541675f2f..ee5ed1b8a 100644 --- a/server/internal/webrtc/manager.go +++ b/server/internal/webrtc/manager.go @@ -478,7 +478,6 @@ func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.Sess // if viper.GetBool("legacy") { - // handle legacy data channel dc.OnMessage(func(message webrtc.DataChannelMessage) { if err := manager.handleLegacy(logger, message.Data, session); err != nil { logger.Err(err).Msg("data handle failed") diff --git a/server/pkg/xorg/xorg.c b/server/pkg/xorg/xorg.c index 8542a0188..3f8aa2136 100644 --- a/server/pkg/xorg/xorg.c +++ b/server/pkg/xorg/xorg.c @@ -35,16 +35,16 @@ void XScroll(int deltaX, int deltaY) { int ydir; if (deltaY > 0) { - ydir = 4; // button 4 is up + ydir = 5; // positive = scroll down = button 5 } else { - ydir = 5; // button 5 is down + ydir = 4; // negative = scroll up = button 4 } int xdir; if (deltaX > 0) { - xdir = 6; // button 6 is right + xdir = 7; // positive = scroll right = button 7 } else { - xdir = 7; // button 7 is left + xdir = 6; // negative = scroll left = button 6 } for (int i = 0; i < abs(deltaY); i++) { From 81d56f758568b05fabed4c8e2a20fd2936d08d57 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 10:11:59 -0400 Subject: [PATCH 4/7] refactor: remove unused CDP scroll client The cdpscroll package and CDPScrollURL config were from an earlier approach and are not used by the XI2.1 scroll path. Made-with: Cursor --- server/internal/config/desktop.go | 8 - server/internal/desktop/manager.go | 13 -- server/pkg/cdpscroll/cdpscroll.go | 230 ----------------------------- 3 files changed, 251 deletions(-) delete mode 100644 server/pkg/cdpscroll/cdpscroll.go diff --git a/server/internal/config/desktop.go b/server/internal/config/desktop.go index e891cfdb4..8331035e7 100644 --- a/server/internal/config/desktop.go +++ b/server/internal/config/desktop.go @@ -23,8 +23,6 @@ type Desktop struct { Unminimize bool UploadDrop bool FileChooserDialog bool - - CDPScrollURL string } func (Desktop) Init(cmd *cobra.Command) error { @@ -63,11 +61,6 @@ func (Desktop) Init(cmd *cobra.Command) error { return err } - cmd.PersistentFlags().String("desktop.cdp_scroll_url", "", "Chromium devtools HTTP base URL for pixel-precise scroll via CDP (e.g. http://127.0.0.1:9223)") - if err := viper.BindPFlag("desktop.cdp_scroll_url", cmd.PersistentFlags().Lookup("desktop.cdp_scroll_url")); err != nil { - return err - } - return nil } @@ -114,7 +107,6 @@ func (s *Desktop) Set() { s.Unminimize = viper.GetBool("desktop.unminimize") s.UploadDrop = viper.GetBool("desktop.upload_drop") s.FileChooserDialog = viper.GetBool("desktop.file_chooser_dialog") - s.CDPScrollURL = viper.GetString("desktop.cdp_scroll_url") } func (s *Desktop) SetV2() { diff --git a/server/internal/desktop/manager.go b/server/internal/desktop/manager.go index 1412b3548..ccae14704 100644 --- a/server/internal/desktop/manager.go +++ b/server/internal/desktop/manager.go @@ -11,7 +11,6 @@ import ( "github.com/rs/zerolog/log" "github.com/m1k1o/neko/server/internal/config" - "github.com/m1k1o/neko/server/pkg/cdpscroll" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/xevent" "github.com/m1k1o/neko/server/pkg/xinput" @@ -28,7 +27,6 @@ type DesktopManagerCtx struct { config *config.Desktop screenSize types.ScreenSize // cached screen size input xinput.Driver - cdpScroll *cdpscroll.Client // Clipboard process holding the most recent clipboard data. // It must remain running to allow pasting clipboard data. @@ -45,12 +43,6 @@ func New(config *config.Desktop) *DesktopManagerCtx { input = xinput.NewDummy() } - var cdp *cdpscroll.Client - if config.CDPScrollURL != "" { - cdp = cdpscroll.New(config.CDPScrollURL) - log.Info().Str("url", config.CDPScrollURL).Msg("CDP scroll enabled") - } - return &DesktopManagerCtx{ logger: log.With().Str("module", "desktop").Logger(), shutdown: make(chan struct{}), @@ -58,7 +50,6 @@ func New(config *config.Desktop) *DesktopManagerCtx { config: config, screenSize: config.ScreenSize, input: input, - cdpScroll: cdp, } } @@ -149,10 +140,6 @@ func (manager *DesktopManagerCtx) Shutdown() error { close(manager.shutdown) - if manager.cdpScroll != nil { - manager.cdpScroll.Close() - } - manager.replaceClipboardCommand(nil) manager.wg.Wait() diff --git a/server/pkg/cdpscroll/cdpscroll.go b/server/pkg/cdpscroll/cdpscroll.go deleted file mode 100644 index ce4e242b4..000000000 --- a/server/pkg/cdpscroll/cdpscroll.go +++ /dev/null @@ -1,230 +0,0 @@ -package cdpscroll - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -// Client sends pixel-precise mouseWheel events to Chromium via CDP. -// It auto-discovers the browser WebSocket URL from the devtools HTTP endpoint. -type Client struct { - logger zerolog.Logger - baseURL string // e.g. "http://127.0.0.1:9223" - mu sync.Mutex - conn *websocket.Conn - msgID atomic.Int64 - sessionID string -} - -// New creates a CDP scroll client. baseURL is the Chromium devtools HTTP -// endpoint (e.g. "http://127.0.0.1:9223"). -func New(baseURL string) *Client { - return &Client{ - logger: log.With().Str("module", "cdpscroll").Logger(), - baseURL: baseURL, - } -} - -type cdpMessage struct { - ID int64 `json:"id"` - Method string `json:"method,omitempty"` - Params map[string]any `json:"params,omitempty"` - SessionID string `json:"sessionId,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *cdpError `json:"error,omitempty"` -} - -type cdpError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (c *Client) discoverWSURL(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/json/version", nil) - if err != nil { - return "", err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("discover CDP URL: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var version struct { - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` - } - if err := json.Unmarshal(body, &version); err != nil { - return "", fmt.Errorf("parse /json/version: %w", err) - } - if version.WebSocketDebuggerURL == "" { - return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version") - } - return version.WebSocketDebuggerURL, nil -} - -func (c *Client) connect(ctx context.Context) error { - if c.conn != nil { - return nil - } - - wsURL, err := c.discoverWSURL(ctx) - if err != nil { - return err - } - - dialer := websocket.Dialer{ - HandshakeTimeout: 2 * time.Second, - } - conn, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) - if err != nil { - return fmt.Errorf("cdp dial: %w", err) - } - c.conn = conn - - if err := c.attachToPage(ctx); err != nil { - c.conn.Close() - c.conn = nil - return err - } - - c.logger.Info().Str("url", wsURL).Msg("CDP scroll connected") - return nil -} - -func (c *Client) send(ctx context.Context, method string, params map[string]any, sessionID string) (json.RawMessage, error) { - id := c.msgID.Add(1) - msg := cdpMessage{ - ID: id, - Method: method, - Params: params, - SessionID: sessionID, - } - - if err := c.conn.WriteJSON(msg); err != nil { - return nil, fmt.Errorf("cdp write %s: %w", method, err) - } - - deadline, ok := ctx.Deadline() - if !ok { - deadline = time.Now().Add(3 * time.Second) - } - c.conn.SetReadDeadline(deadline) - - for { - var resp cdpMessage - if err := c.conn.ReadJSON(&resp); err != nil { - return nil, fmt.Errorf("cdp read %s: %w", method, err) - } - if resp.ID == id { - if resp.Error != nil { - return nil, fmt.Errorf("cdp %s error: %s", method, resp.Error.Message) - } - return resp.Result, nil - } - } -} - -func (c *Client) attachToPage(ctx context.Context) error { - result, err := c.send(ctx, "Target.getTargets", nil, "") - if err != nil { - return err - } - - var targets struct { - TargetInfos []struct { - TargetID string `json:"targetId"` - Type string `json:"type"` - } `json:"targetInfos"` - } - if err := json.Unmarshal(result, &targets); err != nil { - return fmt.Errorf("parse targets: %w", err) - } - - var pageTargetID string - for _, t := range targets.TargetInfos { - if t.Type == "page" { - pageTargetID = t.TargetID - break - } - } - if pageTargetID == "" { - return fmt.Errorf("no page target found") - } - - result, err = c.send(ctx, "Target.attachToTarget", map[string]any{ - "targetId": pageTargetID, - "flatten": true, - }, "") - if err != nil { - return err - } - - var attach struct { - SessionID string `json:"sessionId"` - } - if err := json.Unmarshal(result, &attach); err != nil { - return fmt.Errorf("parse attach: %w", err) - } - c.sessionID = attach.SessionID - return nil -} - -func (c *Client) Close() { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - c.conn.Close() - c.conn = nil - c.sessionID = "" - } -} - -// DispatchScroll sends a pixel-precise mouseWheel event via CDP. -// modifiers is a bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. -func (c *Client) DispatchScroll(x, y int, deltaX, deltaY float64, modifiers int) error { - c.mu.Lock() - defer c.mu.Unlock() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - if err := c.connect(ctx); err != nil { - return err - } - - params := map[string]any{ - "type": "mouseWheel", - "x": x, - "y": y, - "deltaX": deltaX, - "deltaY": deltaY, - } - if modifiers != 0 { - params["modifiers"] = modifiers - } - - _, err := c.send(ctx, "Input.dispatchMouseEvent", params, c.sessionID) - if err != nil { - c.conn.Close() - c.conn = nil - c.sessionID = "" - return err - } - return nil -} From 7f3f3050002f55db8b1931eddaebe4d73e3aca2d Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 14:06:05 -0400 Subject: [PATCH 5/7] fix: dummy xinput driver returns error on Scroll to trigger XTest fallback When UseInputDriver is false, the dummy driver's Scroll() previously returned nil, silently dropping all scroll events. Now it returns errNotConnected so the XTest fallback path in xorg.go is reached. Made-with: Cursor --- server/pkg/xinput/dummy.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/pkg/xinput/dummy.go b/server/pkg/xinput/dummy.go index cfea25d9d..5c31e8b20 100644 --- a/server/pkg/xinput/dummy.go +++ b/server/pkg/xinput/dummy.go @@ -1,6 +1,11 @@ package xinput -import "time" +import ( + "errors" + "time" +) + +var errNotConnected = errors.New("xinput driver not connected") type dummy struct{} @@ -31,5 +36,5 @@ func (d *dummy) TouchEnd(touchId uint32, x, y int, pressure uint8) error { } func (d *dummy) Scroll(deltaX, deltaY int32) error { - return nil + return errNotConnected } From 469c71162931501467ff44ebc92d5df192d6747b Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 14:18:27 -0400 Subject: [PATCH 6/7] fix: eliminate scroll log spam and restore XTest controlKey atomicity - Remove warn-level log on every scroll when xinput driver is unavailable (dummy driver returns error by design, not worth logging) - Pass controlKey to xorg.Scroll() in the XTest fallback path so the modifier set/scroll/clear happens atomically under a single X11 lock Made-with: Cursor --- server/internal/desktop/xorg.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index 2912cb14a..2f8cb0517 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -19,15 +19,17 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) { } func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { - if controlKey { - xorg.SetKeyboardModifier(xorg.KbdModControl, true) - defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) + // Try xinput driver first (XI2.1 smooth scrolling via xf86-input-neko) + if err := manager.input.Scroll(int32(deltaX), int32(deltaY)); err == nil { + if controlKey { + xorg.SetKeyboardModifier(xorg.KbdModControl, true) + defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) + } + return } - 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) - } + // XTest fallback — handles controlKey atomically under a single X11 lock + xorg.Scroll(deltaX, deltaY, controlKey) } func (manager *DesktopManagerCtx) ButtonDown(code uint32) error { From ae3594ac2af90ad82437e394146ab883f9bc7f8c Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 14:28:55 -0400 Subject: [PATCH 7/7] fix: set control modifier before xinput scroll dispatch Branch on UseInputDriver to avoid dummy driver log spam while ensuring the Ctrl modifier is set before xf86-input-neko posts the motion event. The XTest fallback still handles controlKey atomically under a single lock. Made-with: Cursor --- server/internal/desktop/xorg.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go index 2f8cb0517..907f1abc1 100644 --- a/server/internal/desktop/xorg.go +++ b/server/internal/desktop/xorg.go @@ -19,17 +19,21 @@ func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) { } func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) { - // Try xinput driver first (XI2.1 smooth scrolling via xf86-input-neko) - if err := manager.input.Scroll(int32(deltaX), int32(deltaY)); err == nil { + if manager.config.UseInputDriver { + // XI2.1 smooth scrolling via xf86-input-neko: set modifier before the + // driver posts the motion event so the X server sees Ctrl held. if controlKey { xorg.SetKeyboardModifier(xorg.KbdModControl, true) defer xorg.SetKeyboardModifier(xorg.KbdModControl, false) } - return + 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) + } + } else { + // XTest fallback — handles controlKey atomically under a single X11 lock + xorg.Scroll(deltaX, deltaY, controlKey) } - - // XTest fallback — handles controlKey atomically under a single X11 lock - xorg.Scroll(deltaX, deltaY, controlKey) } func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {