Skip to content
126 changes: 116 additions & 10 deletions server/cmd/api/api/computer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"math"
"math/rand"
"os"
"os/exec"
"strconv"
Expand All @@ -15,6 +17,7 @@ import (
"time"

"github.com/onkernel/kernel-images/server/lib/logger"
"github.com/onkernel/kernel-images/server/lib/mousetrajectory"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
)

Expand Down Expand Up @@ -51,34 +54,32 @@ func (s *ApiService) doMoveMouse(ctx context.Context, body oapi.MoveMouseRequest
return &validationError{msg: fmt.Sprintf("coordinates exceed screen bounds (max: %dx%d)", screenWidth-1, screenHeight-1)}
}

// Build xdotool arguments
args := []string{}
useSmooth := body.Smooth == nil || *body.Smooth // default true when omitted
if useSmooth {
return s.doMoveMouseSmooth(ctx, log, body)
}
return s.doMoveMouseInstant(ctx, log, body)
}

// Hold modifier keys (keydown)
func (s *ApiService) doMoveMouseInstant(ctx context.Context, log *slog.Logger, body oapi.MoveMouseRequest) error {
args := []string{}
if body.HoldKeys != nil {
for _, key := range *body.HoldKeys {
args = append(args, "keydown", key)
}
}

// Move the cursor to the desired coordinates
args = append(args, "mousemove", strconv.Itoa(body.X), strconv.Itoa(body.Y))

// Release modifier keys (keyup)
if body.HoldKeys != nil {
for _, key := range *body.HoldKeys {
args = append(args, "keyup", key)
}
}

log.Info("executing xdotool", "args", args)

output, err := defaultXdoTool.Run(ctx, args...)
if err != nil {
log.Error("xdotool command failed", "err", err, "output", string(output))
return &executionError{msg: "failed to move mouse"}
}

return nil
}

Expand All @@ -100,6 +101,111 @@ func (s *ApiService) MoveMouse(ctx context.Context, request oapi.MoveMouseReques
return oapi.MoveMouse200Response{}, nil
}

func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, body oapi.MoveMouseRequest) error {
fromX, fromY, err := s.getMouseLocation(ctx)
if err != nil {
log.Error("failed to get mouse location for smooth move", "error", err)
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}
}

traj := mousetrajectory.NewHumanizeMouseTrajectoryWithOptions(
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 := 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
}
}

// Hold modifiers
if body.HoldKeys != nil {
args := []string{}
for _, key := range *body.HoldKeys {
args = append(args, "keydown", key)
}
if output, err := defaultXdoTool.Run(ctx, args...); err != nil {
log.Error("xdotool keydown failed", "err", err, "output", string(output))
return &executionError{msg: "failed to hold modifier keys"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty HoldKeys array fails in smooth mode

Low Severity

When hold_keys is an empty array (non-nil pointer to []string{}), doMoveMouseSmooth enters the body.HoldKeys != nil block but appends no keydown args, then calls defaultXdoTool.Run(ctx) with zero arguments. This runs bare xdotool which prints help and exits with an error, causing a 500 "failed to hold modifier keys" response. doMoveMouseInstant handles this correctly since the empty loop just adds nothing to the shared args slice, which already contains mousemove x y.

Fix in Cursor Fix in Web

}
defer func() {
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...)
}()
}

// Move along Bezier path: mousemove_relative for each step with delay
for i := 1; i < len(points); i++ {
select {
case <-ctx.Done():
return &executionError{msg: "mouse movement cancelled"}
default:
}

dx := points[i][0] - points[i-1][0]
dy := points[i][1] - points[i-1][1]
if dx == 0 && dy == 0 {
continue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped zero-delta steps break requested movement duration

Medium Severity

When consecutive trajectory points round to the same integer coordinates (common for short-distance moves or high point counts), the continue on dx == 0 && dy == 0 skips both the xdotool call and the sleepWithContext. The stepDelayMs was computed assuming all numSteps steps would execute a sleep, so skipping sleeps causes the actual movement duration to be significantly shorter than the requested DurationSec. For a 20-pixel move with DurationSec=2.0, the many duplicate integer points mean only a fraction of steps actually sleep, producing a sub-second movement instead of the intended 2 seconds.

Additional Locations (1)

Fix in Cursor Fix in Web

args := []string{"mousemove_relative", "--", strconv.Itoa(dx), strconv.Itoa(dy)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative moves drift when Bezier goes off-screen

Medium Severity

The trajectory's boundsPadding = 80 places Bezier control knots up to 80 pixels beyond the start/end bounding box, so intermediate curve points can have negative coordinates when the mouse starts near a screen edge. Since doMoveMouseSmooth uses mousemove_relative, and the X11 server clamps the cursor at screen boundaries, the accumulated relative deltas no longer sum to the correct total displacement. The cursor ends up at the wrong final position, potentially missing subsequent click targets.

Additional Locations (1)

Fix in Cursor Fix in Web

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"}
}
jitter := stepDelayMs
if stepDelayMs > 3 {
jitter = stepDelayMs + rand.Intn(5) - 2
if jitter < 3 {
jitter = 3
}
}
if err := sleepWithContext(ctx, time.Duration(jitter)*time.Millisecond); err != nil {
return &executionError{msg: "mouse movement cancelled"}
}
}

log.Info("executed smooth mouse movement", "points", len(points))
return 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")
if err != nil {
return 0, 0, fmt.Errorf("xdotool getmouselocation failed: %w (output: %s)", err, string(output))
}
return parseMousePosition(string(output))
}

func (s *ApiService) doClickMouse(ctx context.Context, body oapi.ClickMouseRequest) error {
log := logger.FromContext(ctx)

Expand Down
Loading
Loading