Skip to content

Commit 4198c2d

Browse files
authored
[kernel-847] add functionality to get cursor position via API (#141)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Introduces a new host-input API surface that shells out to `xdotool`; main risk is runtime fragility across environments and contention with other input operations despite mutex serialization. > > **Overview** > Adds a new Computer API endpoint `POST /computer/get_mouse_position` that returns the current cursor coordinates as `{x,y}`. > > Implements the handler by running `xdotool getmouselocation --shell`, parsing the output via a new `parseMousePosition` helper (with input locking and error reporting), and updates generated OAPI client/server types plus unit tests for the parser. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a372758. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 24f62d0 commit 4198c2d

4 files changed

Lines changed: 492 additions & 98 deletions

File tree

server/cmd/api/api/computer.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"strconv"
12+
"strings"
1213
"syscall"
1314
"time"
1415

@@ -426,6 +427,85 @@ func (s *ApiService) SetCursor(ctx context.Context, request oapi.SetCursorReques
426427
return oapi.SetCursor200JSONResponse{Ok: true}, nil
427428
}
428429

430+
// parseMousePosition parses xdotool getmouselocation --shell output.
431+
// Expected format:
432+
//
433+
// X=100
434+
// Y=200
435+
// SCREEN=0
436+
// WINDOW=12345
437+
//
438+
// Returns x, y coordinates and an error if parsing fails.
439+
func parseMousePosition(output string) (x, y int, err error) {
440+
outStr := strings.TrimSpace(output)
441+
if outStr == "" {
442+
return 0, 0, fmt.Errorf("empty output")
443+
}
444+
445+
var xParsed, yParsed bool
446+
447+
for line := range strings.SplitSeq(outStr, "\n") {
448+
line = strings.TrimSpace(line)
449+
parts := strings.SplitN(line, "=", 2)
450+
if len(parts) != 2 {
451+
continue
452+
}
453+
key, value := parts[0], parts[1]
454+
switch key {
455+
case "X":
456+
if parsed, parseErr := strconv.Atoi(value); parseErr == nil {
457+
x = parsed
458+
xParsed = true
459+
}
460+
case "Y":
461+
if parsed, parseErr := strconv.Atoi(value); parseErr == nil {
462+
y = parsed
463+
yParsed = true
464+
}
465+
}
466+
// Early exit once both coordinates are found
467+
if xParsed && yParsed {
468+
break
469+
}
470+
}
471+
472+
if !xParsed || !yParsed {
473+
return 0, 0, fmt.Errorf("failed to parse coordinates from output: %q", outStr)
474+
}
475+
476+
return x, y, nil
477+
}
478+
479+
func (s *ApiService) GetMousePosition(ctx context.Context, request oapi.GetMousePositionRequestObject) (oapi.GetMousePositionResponseObject, error) {
480+
log := logger.FromContext(ctx)
481+
482+
// serialize input operations to avoid race conditions with other xdotool commands
483+
s.inputMu.Lock()
484+
defer s.inputMu.Unlock()
485+
486+
// Execute xdotool getmouselocation --shell for parseable output
487+
output, err := defaultXdoTool.Run(ctx, "getmouselocation", "--shell")
488+
if err != nil {
489+
log.Error("xdotool getmouselocation failed", "err", err, "output", string(output))
490+
return oapi.GetMousePosition500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
491+
Message: "failed to get mouse position"},
492+
}, nil
493+
}
494+
495+
x, y, err := parseMousePosition(string(output))
496+
if err != nil {
497+
log.Error("failed to parse mouse position", "err", err, "output", string(output))
498+
return oapi.GetMousePosition500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
499+
Message: "failed to parse mouse position from xdotool output"},
500+
}, nil
501+
}
502+
503+
return oapi.GetMousePosition200JSONResponse{
504+
X: x,
505+
Y: y,
506+
}, nil
507+
}
508+
429509
func (s *ApiService) PressKey(ctx context.Context, request oapi.PressKeyRequestObject) (oapi.PressKeyResponseObject, error) {
430510
log := logger.FromContext(ctx)
431511

server/cmd/api/api/computer_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,94 @@ func TestGenerateRelativeSteps_DiagonalsAndSlopes(t *testing.T) {
4949
require.Equal(t, 5, countSteps(steps), "count mismatch")
5050
}
5151
}
52+
53+
// TestParseMousePosition tests the parseMousePosition helper function
54+
func TestParseMousePosition(t *testing.T) {
55+
tests := []struct {
56+
name string
57+
output string
58+
expectX int
59+
expectY int
60+
expectError bool
61+
}{
62+
{
63+
name: "valid output",
64+
output: "X=100\nY=200\nSCREEN=0\nWINDOW=12345\n",
65+
expectX: 100,
66+
expectY: 200,
67+
expectError: false,
68+
},
69+
{
70+
name: "valid output with extra whitespace",
71+
output: " X=512 \n Y=384 \n SCREEN=0 \n WINDOW=67890 \n",
72+
expectX: 512,
73+
expectY: 384,
74+
expectError: false,
75+
},
76+
{
77+
name: "missing Y coordinate",
78+
output: "X=100\nSCREEN=0\nWINDOW=12345\n",
79+
expectError: true,
80+
},
81+
{
82+
name: "missing X coordinate",
83+
output: "Y=200\nSCREEN=0\nWINDOW=12345\n",
84+
expectError: true,
85+
},
86+
{
87+
name: "empty output",
88+
output: "",
89+
expectError: true,
90+
},
91+
{
92+
name: "whitespace only",
93+
output: " \n \t \n",
94+
expectError: true,
95+
},
96+
{
97+
name: "non-numeric X value",
98+
output: "X=abc\nY=200\nSCREEN=0\nWINDOW=12345\n",
99+
expectError: true,
100+
},
101+
{
102+
name: "non-numeric Y value",
103+
output: "X=100\nY=xyz\nSCREEN=0\nWINDOW=12345\n",
104+
expectError: true,
105+
},
106+
{
107+
name: "zero coordinates",
108+
output: "X=0\nY=0\nSCREEN=0\nWINDOW=12345\n",
109+
expectX: 0,
110+
expectY: 0,
111+
expectError: false,
112+
},
113+
{
114+
name: "negative coordinates",
115+
output: "X=-50\nY=-100\nSCREEN=0\nWINDOW=12345\n",
116+
expectX: -50,
117+
expectY: -100,
118+
expectError: false,
119+
},
120+
{
121+
name: "large coordinates",
122+
output: "X=3840\nY=2160\nSCREEN=0\nWINDOW=12345\n",
123+
expectX: 3840,
124+
expectY: 2160,
125+
expectError: false,
126+
},
127+
}
128+
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
x, y, err := parseMousePosition(tt.output)
132+
133+
if tt.expectError {
134+
require.Error(t, err, "expected parsing to fail")
135+
} else {
136+
require.NoError(t, err, "expected successful parsing")
137+
require.Equal(t, tt.expectX, x, "X coordinate mismatch")
138+
require.Equal(t, tt.expectY, y, "Y coordinate mismatch")
139+
}
140+
})
141+
}
142+
}

0 commit comments

Comments
 (0)