Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: simplify MoveMouse handler, fix duration_sec accuracy
- Eliminate duplicated validation by having MoveMouse call doMoveMouse
  directly instead of reimplementing resolution/bounds checks
- Remove unnecessary moveMouseSmooth/moveMouseInstant wrapper functions
- Compute trajectory MaxPoints from duration_sec so the requested
  duration is actually achievable (remove 30ms step delay upper clamp)
- Skip zero-delta mousemove_relative steps to avoid no-op xdotool calls
- Always pass -- to mousemove_relative for robustness with negative args
- Remove redundant nil check in deferred HoldKeys cleanup
- Clean up duration_sec OpenAPI description (remove internal references)
- Rename defaultMaxTime/defaultMinTime to defaultMaxPoints/defaultMinPoints
- Export MinPoints/MaxPoints constants from mousetrajectory package
- Add clamping tests for MaxPoints below min and above max

Co-authored-by: Cursor <cursoragent@cursor.com>
  • Loading branch information
ulziibay-kernel and cursoragent committed Feb 11, 2026
commit 6397a136bf88c75b2b7388df340d0cb971d15fb4
90 changes: 29 additions & 61 deletions server/cmd/api/api/computer.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,40 +92,7 @@ func (s *ApiService) MoveMouse(ctx context.Context, request oapi.MoveMouseReques
Message: "request body is required"},
}, nil
}
body := *request.Body

// Get current resolution for bounds validation
screenWidth, screenHeight, _, err := s.getCurrentResolution(ctx)
if err != nil {
log := logger.FromContext(ctx)
log.Error("failed to get current resolution", "error", err)
return oapi.MoveMouse500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: "failed to get current display resolution"},
}, nil
}

// Ensure non-negative coordinates and within screen bounds
if body.X < 0 || body.Y < 0 {
return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: "coordinates must be non-negative"},
}, nil
}
if body.X >= screenWidth || body.Y >= screenHeight {
return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: fmt.Sprintf("coordinates exceed screen bounds (max: %dx%d)", screenWidth-1, screenHeight-1)},
}, nil
}

useSmooth := body.Smooth == nil || *body.Smooth // default true when omitted
log := logger.FromContext(ctx)
if useSmooth {
return s.moveMouseSmooth(ctx, log, body)
}
return s.moveMouseInstant(ctx, log, body)
}

func (s *ApiService) moveMouseInstant(ctx context.Context, log *slog.Logger, body oapi.MoveMouseRequest) (oapi.MoveMouseResponseObject, error) {
if err := s.doMoveMouseInstant(ctx, log, body); err != nil {
if err := s.doMoveMouse(ctx, *request.Body); err != nil {
if isValidationErr(err) {
return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil
}
Expand All @@ -141,24 +108,39 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
return &executionError{msg: "failed to get current mouse position: " + err.Error()}
}

// When duration_sec is specified, compute the number of trajectory points
// to achieve that duration at a ~10ms step delay (human-like event frequency).
// Otherwise let the library auto-compute from path length.
const defaultStepDelayMs = 10
var opts *mousetrajectory.Options
if body.DurationSec != nil && *body.DurationSec >= 0.05 && *body.DurationSec <= 5 {
durationMs := int(*body.DurationSec * 1000)
targetPoints := durationMs / defaultStepDelayMs
if targetPoints < mousetrajectory.MinPoints {
targetPoints = mousetrajectory.MinPoints
}
if targetPoints > mousetrajectory.MaxPoints {
targetPoints = mousetrajectory.MaxPoints
}
opts = &mousetrajectory.Options{MaxPoints: targetPoints}
}
Comment thread
ulziibay-kernel marked this conversation as resolved.

traj := mousetrajectory.NewHumanizeMouseTrajectoryWithOptions(
float64(fromX), float64(fromY), float64(body.X), float64(body.Y), nil)
float64(fromX), float64(fromY), float64(body.X), float64(body.Y), opts)
points := traj.GetPointsInt()
if len(points) < 2 {
return s.doMoveMouseInstant(ctx, log, body)
}

// Compute per-step delay to achieve the target duration.
numSteps := len(points) - 1
stepDelayMs := 10 // default when duration_sec not specified
stepDelayMs := defaultStepDelayMs
if body.DurationSec != nil && *body.DurationSec >= 0.05 && *body.DurationSec <= 5 && numSteps > 0 {
durationMs := int(*body.DurationSec * 1000)
stepDelayMs = durationMs / numSteps
if stepDelayMs < 3 {
stepDelayMs = 3
}
if stepDelayMs > 30 {
stepDelayMs = 30
}
}
Comment thread
ulziibay-kernel marked this conversation as resolved.

// Hold modifiers
Expand All @@ -172,14 +154,12 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
return &executionError{msg: "failed to hold modifier keys"}
Comment thread
ulziibay-kernel marked this conversation as resolved.
}
defer func() {
if body.HoldKeys != nil {
args := []string{}
for _, key := range *body.HoldKeys {
args = append(args, "keyup", key)
}
// Use background context for cleanup so keys are released even on cancellation.
_, _ = defaultXdoTool.Run(context.Background(), args...)
args := []string{}
for _, key := range *body.HoldKeys {
args = append(args, "keyup", key)
}
// Use background context for cleanup so keys are released even on cancellation.
_, _ = defaultXdoTool.Run(context.Background(), args...)
}()
}

Expand All @@ -193,12 +173,10 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo

dx := points[i][0] - points[i-1][0]
dy := points[i][1] - points[i-1][1]
args := []string{"mousemove_relative"}
if dx < 0 || dy < 0 {
args = append(args, "--", strconv.Itoa(dx), strconv.Itoa(dy))
} else {
args = append(args, strconv.Itoa(dx), strconv.Itoa(dy))
if dx == 0 && dy == 0 {
continue
}
Comment thread
ulziibay-kernel marked this conversation as resolved.
Outdated
args := []string{"mousemove_relative", "--", strconv.Itoa(dx), strconv.Itoa(dy)}
Comment thread
ulziibay-kernel marked this conversation as resolved.
Outdated
if output, err := defaultXdoTool.Run(ctx, args...); err != nil {
log.Error("xdotool mousemove_relative failed", "err", err, "output", string(output), "step", i)
return &executionError{msg: "failed during smooth mouse movement"}
Expand All @@ -219,16 +197,6 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
return nil
}

func (s *ApiService) moveMouseSmooth(ctx context.Context, log *slog.Logger, body oapi.MoveMouseRequest) (oapi.MoveMouseResponseObject, error) {
if err := s.doMoveMouseSmooth(ctx, log, body); err != nil {
if isValidationErr(err) {
return oapi.MoveMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil
}
return oapi.MoveMouse500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: err.Error()}}, nil
}
return oapi.MoveMouse200Response{}, nil
}

// getMouseLocation returns the current cursor position via xdotool getmouselocation --shell.
func (s *ApiService) getMouseLocation(ctx context.Context) (x, y int, err error) {
output, err := defaultXdoTool.Run(ctx, "getmouselocation", "--shell")
Expand Down
28 changes: 15 additions & 13 deletions server/lib/mousetrajectory/mousetrajectory.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,19 @@ const (
// Number of internal knots for the Bezier curve (more = curvier).
knotsCount = 2
// Distortion parameters for human-like jitter: mean, stdev, frequency.
distortionMean = 1.0
distortionMean = 1.0
distortionStDev = 1.0
distortionFreq = 0.5
distortionFreq = 0.5
)

const (
defaultMaxTime = 150
defaultMinTime = 0
pathLengthScale = 20 // Multiplier for path-length-based point count
minPoints = 5
maxPoints = 80
defaultMaxPoints = 150 // Upper bound for auto-computed point count
defaultMinPoints = 0 // Lower bound for auto-computed point count (before clamp to MinPoints)
pathLengthScale = 20 // Multiplier for path-length-based point count
// MinPoints is the minimum number of trajectory points.
MinPoints = 5
// MaxPoints is the maximum number of trajectory points.
MaxPoints = 80
)

func (t *HumanizeMouseTrajectory) generateCurve(opts *Options) {
Expand Down Expand Up @@ -202,16 +204,16 @@ func (t *HumanizeMouseTrajectory) tweenPoints(points [][2]float64, opts *Options
}

targetPoints := int(math.Min(
float64(defaultMaxTime),
math.Max(float64(defaultMinTime+2), math.Pow(totalLength, 0.25)*pathLengthScale)))
float64(defaultMaxPoints),
math.Max(float64(defaultMinPoints+2), math.Pow(totalLength, 0.25)*pathLengthScale)))

if opts != nil && opts.MaxPoints > 0 {
maxPts := opts.MaxPoints
if maxPts < minPoints {
maxPts = minPoints
if maxPts < MinPoints {
maxPts = MinPoints
}
if maxPts > maxPoints {
maxPts = maxPoints
if maxPts > MaxPoints {
maxPts = MaxPoints
}
targetPoints = maxPts
}
Expand Down
18 changes: 18 additions & 0 deletions server/lib/mousetrajectory/mousetrajectory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ func TestHumanizeMouseTrajectory_ZeroLengthPath(t *testing.T) {
assert.Equal(t, 0, points[len(points)-1][1])
}

func TestHumanizeMouseTrajectory_MaxPointsClampedToMin(t *testing.T) {
// MaxPoints below MinPoints should be clamped up to MinPoints
opts := &Options{MaxPoints: 2}
traj := NewHumanizeMouseTrajectoryWithOptions(0, 0, 100, 100, opts)
points := traj.GetPointsInt()

assert.Len(t, points, MinPoints, "MaxPoints below MinPoints should clamp to MinPoints")
}

func TestHumanizeMouseTrajectory_MaxPointsClampedToMax(t *testing.T) {
// MaxPoints above MaxPoints should be clamped down to MaxPoints
opts := &Options{MaxPoints: 200}
traj := NewHumanizeMouseTrajectoryWithOptions(0, 0, 100, 100, opts)
points := traj.GetPointsInt()

assert.Len(t, points, MaxPoints, "MaxPoints above MaxPoints should clamp to MaxPoints")
}

func TestHumanizeMouseTrajectory_CurvedPath(t *testing.T) {
traj := NewHumanizeMouseTrajectoryWithSeed(0, 0, 100, 0, 999)
points := traj.GetPointsInt()
Expand Down
Loading
Loading