@jian-ai/remote-control 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/bin/computer-linux.mjs +537 -0
- package/bin/computer-macos.mjs +255 -59
- package/bin/computer-shared.mjs +61 -0
- package/bin/computer-windows.mjs +516 -0
- package/bin/computer.mjs +63 -0
- package/bin/jian-remote.mjs +44 -27
- package/linux/computer-helper.py +694 -0
- package/macos/build-helper.sh +46 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +300 -131
- package/package.json +3 -1
- package/windows/computer-helper.ps1 +500 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Jian Remote Control — Linux computer-use helper (X11).
|
|
4
|
+
|
|
5
|
+
Newline-delimited JSON on stdin → JSON lines on stdout (same cmds as macOS helper).
|
|
6
|
+
Overlay: override-redirect window with geometric white pointer + soft blue glow.
|
|
7
|
+
Input: xdotool when available (save/restore cursor around clicks so the user's
|
|
8
|
+
pointer is not left at the Jian position). Wayland: best-effort; overlay may be
|
|
9
|
+
unavailable — input still works when ydotool/wtype/xdotool can reach the session.
|
|
10
|
+
|
|
11
|
+
English-only stderr. No third-party Python packages (ctypes + libX11).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from ctypes import (
|
|
25
|
+
POINTER,
|
|
26
|
+
Structure,
|
|
27
|
+
byref,
|
|
28
|
+
c_char,
|
|
29
|
+
c_char_p,
|
|
30
|
+
c_int,
|
|
31
|
+
c_long,
|
|
32
|
+
c_short,
|
|
33
|
+
c_uint,
|
|
34
|
+
c_ulong,
|
|
35
|
+
c_ushort,
|
|
36
|
+
c_void_p,
|
|
37
|
+
cdll,
|
|
38
|
+
create_string_buffer,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# --- X11 bindings (minimal) -------------------------------------------------
|
|
42
|
+
|
|
43
|
+
X11 = cdll.LoadLibrary("libX11.so.6")
|
|
44
|
+
|
|
45
|
+
Bool = c_int
|
|
46
|
+
Status = c_int
|
|
47
|
+
Window = c_ulong
|
|
48
|
+
Display = c_void_p
|
|
49
|
+
Atom = c_ulong
|
|
50
|
+
Colormap = c_ulong
|
|
51
|
+
Pixmap = c_ulong
|
|
52
|
+
GC = c_void_p
|
|
53
|
+
Visual = c_void_p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class XPoint(Structure):
|
|
57
|
+
_fields_ = [("x", c_short), ("y", c_short)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class XColor(Structure):
|
|
61
|
+
_fields_ = [
|
|
62
|
+
("pixel", c_ulong),
|
|
63
|
+
("red", c_ushort),
|
|
64
|
+
("green", c_ushort),
|
|
65
|
+
("blue", c_ushort),
|
|
66
|
+
("flags", c_char),
|
|
67
|
+
("pad", c_char),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class XSetWindowAttributes(Structure):
|
|
72
|
+
_fields_ = [
|
|
73
|
+
("background_pixmap", Pixmap),
|
|
74
|
+
("background_pixel", c_ulong),
|
|
75
|
+
("border_pixmap", Pixmap),
|
|
76
|
+
("border_pixel", c_ulong),
|
|
77
|
+
("bit_gravity", c_int),
|
|
78
|
+
("win_gravity", c_int),
|
|
79
|
+
("backing_store", c_int),
|
|
80
|
+
("backing_planes", c_ulong),
|
|
81
|
+
("backing_pixel", c_ulong),
|
|
82
|
+
("save_under", Bool),
|
|
83
|
+
("event_mask", c_long),
|
|
84
|
+
("do_not_propagate_mask", c_long),
|
|
85
|
+
("override_redirect", Bool),
|
|
86
|
+
("colormap", Colormap),
|
|
87
|
+
("cursor", c_ulong),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
X11.XOpenDisplay.argtypes = [c_char_p]
|
|
92
|
+
X11.XOpenDisplay.restype = Display
|
|
93
|
+
X11.XDefaultScreen.argtypes = [Display]
|
|
94
|
+
X11.XDefaultScreen.restype = c_int
|
|
95
|
+
X11.XRootWindow.argtypes = [Display, c_int]
|
|
96
|
+
X11.XRootWindow.restype = Window
|
|
97
|
+
X11.XDefaultColormap.argtypes = [Display, c_int]
|
|
98
|
+
X11.XDefaultColormap.restype = Colormap
|
|
99
|
+
X11.XDefaultDepth.argtypes = [Display, c_int]
|
|
100
|
+
X11.XDefaultDepth.restype = c_int
|
|
101
|
+
X11.XDefaultVisual.argtypes = [Display, c_int]
|
|
102
|
+
X11.XDefaultVisual.restype = Visual
|
|
103
|
+
X11.XDisplayWidth.argtypes = [Display, c_int]
|
|
104
|
+
X11.XDisplayWidth.restype = c_int
|
|
105
|
+
X11.XDisplayHeight.argtypes = [Display, c_int]
|
|
106
|
+
X11.XDisplayHeight.restype = c_int
|
|
107
|
+
X11.XCreateWindow.argtypes = [
|
|
108
|
+
Display,
|
|
109
|
+
Window,
|
|
110
|
+
c_int,
|
|
111
|
+
c_int,
|
|
112
|
+
c_uint,
|
|
113
|
+
c_uint,
|
|
114
|
+
c_uint,
|
|
115
|
+
c_int,
|
|
116
|
+
c_uint,
|
|
117
|
+
Visual,
|
|
118
|
+
c_ulong,
|
|
119
|
+
POINTER(XSetWindowAttributes),
|
|
120
|
+
]
|
|
121
|
+
X11.XCreateWindow.restype = Window
|
|
122
|
+
X11.XMapRaised.argtypes = [Display, Window]
|
|
123
|
+
X11.XUnmapWindow.argtypes = [Display, Window]
|
|
124
|
+
X11.XMoveWindow.argtypes = [Display, Window, c_int, c_int]
|
|
125
|
+
X11.XClearWindow.argtypes = [Display, Window]
|
|
126
|
+
X11.XFlush.argtypes = [Display]
|
|
127
|
+
X11.XSync.argtypes = [Display, Bool]
|
|
128
|
+
X11.XCreateGC.argtypes = [Display, Window, c_ulong, c_void_p]
|
|
129
|
+
X11.XCreateGC.restype = GC
|
|
130
|
+
X11.XSetForeground.argtypes = [Display, GC, c_ulong]
|
|
131
|
+
X11.XFillPolygon.argtypes = [Display, Window, GC, POINTER(XPoint), c_int, c_int, c_int]
|
|
132
|
+
X11.XDrawLines.argtypes = [Display, Window, GC, POINTER(XPoint), c_int, c_int]
|
|
133
|
+
X11.XAllocNamedColor.argtypes = [Display, Colormap, c_char_p, POINTER(XColor), POINTER(XColor)]
|
|
134
|
+
X11.XAllocNamedColor.restype = Status
|
|
135
|
+
X11.XStoreName.argtypes = [Display, Window, c_char_p]
|
|
136
|
+
X11.XSelectInput.argtypes = [Display, Window, c_long]
|
|
137
|
+
X11.XPending.argtypes = [Display]
|
|
138
|
+
X11.XPending.restype = c_int
|
|
139
|
+
X11.XNextEvent.argtypes = [Display, c_void_p]
|
|
140
|
+
X11.XInternAtom.argtypes = [Display, c_char_p, Bool]
|
|
141
|
+
X11.XInternAtom.restype = Atom
|
|
142
|
+
X11.XChangeProperty.argtypes = [
|
|
143
|
+
Display,
|
|
144
|
+
Window,
|
|
145
|
+
Atom,
|
|
146
|
+
Atom,
|
|
147
|
+
c_int,
|
|
148
|
+
c_int,
|
|
149
|
+
c_void_p,
|
|
150
|
+
c_int,
|
|
151
|
+
]
|
|
152
|
+
X11.XCloseDisplay.argtypes = [Display]
|
|
153
|
+
|
|
154
|
+
CWOverrideRedirect = 1 << 9
|
|
155
|
+
CWBackPixel = 1 << 1
|
|
156
|
+
CWBorderPixel = 1 << 3
|
|
157
|
+
CWEventMask = 1 << 11
|
|
158
|
+
InputOutput = 1
|
|
159
|
+
ExposureMask = 1 << 15
|
|
160
|
+
StructureNotifyMask = 1 << 17
|
|
161
|
+
Complex = 0
|
|
162
|
+
CoordModeOrigin = 0
|
|
163
|
+
PropModeReplace = 0
|
|
164
|
+
|
|
165
|
+
POINTER_SIZE = 36
|
|
166
|
+
HOTSPOT = (2, 2)
|
|
167
|
+
SESSION_ALPHA_NOTE = 0.94 # X11 solid window; translucency is best-effort via shape glow only
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def log_err(msg: str) -> None:
|
|
171
|
+
sys.stderr.write(f"computer-helper: {msg}\n")
|
|
172
|
+
sys.stderr.flush()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_response(obj: dict) -> None:
|
|
176
|
+
sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
|
177
|
+
sys.stdout.flush()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def write_ok(extra: dict | None = None) -> None:
|
|
181
|
+
body = {"ok": True}
|
|
182
|
+
if extra:
|
|
183
|
+
body.update(extra)
|
|
184
|
+
write_response(body)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_error(message: str) -> None:
|
|
188
|
+
write_response({"ok": False, "error": message})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def which(name: str) -> str | None:
|
|
192
|
+
return shutil.which(name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Arrow polygon in local window coords (tip near hotspot).
|
|
196
|
+
ARROW = [
|
|
197
|
+
(2, 2),
|
|
198
|
+
(2, 26),
|
|
199
|
+
(10, 20),
|
|
200
|
+
(15, 32),
|
|
201
|
+
(20, 30),
|
|
202
|
+
(13, 17),
|
|
203
|
+
(22, 17),
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Overlay:
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
self.dpy: Display | None = None
|
|
210
|
+
self.screen = 0
|
|
211
|
+
self.root = 0
|
|
212
|
+
self.win = 0
|
|
213
|
+
self.gc = None
|
|
214
|
+
self.gc_glow = None
|
|
215
|
+
self.gc_ink = None
|
|
216
|
+
self.mapped = False
|
|
217
|
+
self.tip = (0.0, 0.0)
|
|
218
|
+
self.has_tip = False
|
|
219
|
+
self.width_px = 0
|
|
220
|
+
self.height_px = 0
|
|
221
|
+
self._lock = threading.Lock()
|
|
222
|
+
self.available = False
|
|
223
|
+
|
|
224
|
+
def open(self) -> bool:
|
|
225
|
+
display_name = os.environ.get("DISPLAY")
|
|
226
|
+
if not display_name:
|
|
227
|
+
log_err("DISPLAY is not set; overlay unavailable")
|
|
228
|
+
return False
|
|
229
|
+
self.dpy = X11.XOpenDisplay(display_name.encode())
|
|
230
|
+
if not self.dpy:
|
|
231
|
+
log_err(f"could not open X display {display_name}")
|
|
232
|
+
return False
|
|
233
|
+
self.screen = X11.XDefaultScreen(self.dpy)
|
|
234
|
+
self.root = X11.XRootWindow(self.dpy, self.screen)
|
|
235
|
+
self.width_px = X11.XDisplayWidth(self.dpy, self.screen)
|
|
236
|
+
self.height_px = X11.XDisplayHeight(self.dpy, self.screen)
|
|
237
|
+
depth = X11.XDefaultDepth(self.dpy, self.screen)
|
|
238
|
+
visual = X11.XDefaultVisual(self.dpy, self.screen)
|
|
239
|
+
attrs = XSetWindowAttributes()
|
|
240
|
+
attrs.override_redirect = 1
|
|
241
|
+
attrs.background_pixel = 0
|
|
242
|
+
attrs.border_pixel = 0
|
|
243
|
+
attrs.event_mask = ExposureMask | StructureNotifyMask
|
|
244
|
+
mask = CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask
|
|
245
|
+
self.win = X11.XCreateWindow(
|
|
246
|
+
self.dpy,
|
|
247
|
+
self.root,
|
|
248
|
+
0,
|
|
249
|
+
0,
|
|
250
|
+
POINTER_SIZE,
|
|
251
|
+
POINTER_SIZE,
|
|
252
|
+
0,
|
|
253
|
+
depth,
|
|
254
|
+
InputOutput,
|
|
255
|
+
visual,
|
|
256
|
+
mask,
|
|
257
|
+
byref(attrs),
|
|
258
|
+
)
|
|
259
|
+
X11.XStoreName(self.dpy, self.win, b"Jian")
|
|
260
|
+
# _NET_WM_WINDOW_TYPE_NOTIFICATION keeps it above most clients.
|
|
261
|
+
try:
|
|
262
|
+
type_atom = X11.XInternAtom(self.dpy, b"_NET_WM_WINDOW_TYPE", 0)
|
|
263
|
+
notif = X11.XInternAtom(self.dpy, b"_NET_WM_WINDOW_TYPE_NOTIFICATION", 0)
|
|
264
|
+
X11.XChangeProperty(
|
|
265
|
+
self.dpy,
|
|
266
|
+
self.win,
|
|
267
|
+
type_atom,
|
|
268
|
+
X11.XInternAtom(self.dpy, b"ATOM", 0),
|
|
269
|
+
32,
|
|
270
|
+
PropModeReplace,
|
|
271
|
+
byref(c_ulong(notif)),
|
|
272
|
+
1,
|
|
273
|
+
)
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
self.gc = X11.XCreateGC(self.dpy, self.win, 0, None)
|
|
277
|
+
self.gc_glow = X11.XCreateGC(self.dpy, self.win, 0, None)
|
|
278
|
+
self.gc_ink = X11.XCreateGC(self.dpy, self.win, 0, None)
|
|
279
|
+
cmap = X11.XDefaultColormap(self.dpy, self.screen)
|
|
280
|
+
white = XColor()
|
|
281
|
+
exact = XColor()
|
|
282
|
+
if X11.XAllocNamedColor(self.dpy, cmap, b"#FAFAF8", byref(white), byref(exact)):
|
|
283
|
+
X11.XSetForeground(self.dpy, self.gc, white.pixel)
|
|
284
|
+
glow = XColor()
|
|
285
|
+
if X11.XAllocNamedColor(self.dpy, cmap, b"#3A7AD6", byref(glow), byref(exact)):
|
|
286
|
+
X11.XSetForeground(self.dpy, self.gc_glow, glow.pixel)
|
|
287
|
+
ink = XColor()
|
|
288
|
+
if X11.XAllocNamedColor(self.dpy, cmap, b"#171716", byref(ink), byref(exact)):
|
|
289
|
+
X11.XSetForeground(self.dpy, self.gc_ink, ink.pixel)
|
|
290
|
+
self.available = True
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
def _points(self, offset=(0, 0)) -> list[XPoint]:
|
|
294
|
+
ox, oy = offset
|
|
295
|
+
return [XPoint(x=int(x + ox), y=int(y + oy)) for x, y in ARROW]
|
|
296
|
+
|
|
297
|
+
def paint(self) -> None:
|
|
298
|
+
if not self.available or not self.mapped:
|
|
299
|
+
return
|
|
300
|
+
X11.XClearWindow(self.dpy, self.win)
|
|
301
|
+
# Soft “glow”: fill the arrow slightly offset in brand blue, then white on top.
|
|
302
|
+
glow_pts = (XPoint * len(ARROW))(*self._points((1, 2)))
|
|
303
|
+
X11.XFillPolygon(self.dpy, self.win, self.gc_glow, glow_pts, len(ARROW), Complex, CoordModeOrigin)
|
|
304
|
+
pts = (XPoint * len(ARROW))(*self._points())
|
|
305
|
+
X11.XFillPolygon(self.dpy, self.win, self.gc, pts, len(ARROW), Complex, CoordModeOrigin)
|
|
306
|
+
X11.XDrawLines(self.dpy, self.win, self.gc_ink, pts, len(ARROW), CoordModeOrigin)
|
|
307
|
+
# Close the polygon stroke.
|
|
308
|
+
close = (XPoint * 2)(pts[len(ARROW) - 1], pts[0])
|
|
309
|
+
X11.XDrawLines(self.dpy, self.win, self.gc_ink, close, 2, CoordModeOrigin)
|
|
310
|
+
X11.XFlush(self.dpy)
|
|
311
|
+
|
|
312
|
+
def ensure_session(self) -> None:
|
|
313
|
+
if not self.available:
|
|
314
|
+
return
|
|
315
|
+
with self._lock:
|
|
316
|
+
if not self.has_tip:
|
|
317
|
+
self.tip = (self.width_px / 2, self.height_px / 2)
|
|
318
|
+
self.has_tip = True
|
|
319
|
+
if not self.mapped:
|
|
320
|
+
X11.XMapRaised(self.dpy, self.win)
|
|
321
|
+
self.mapped = True
|
|
322
|
+
self._position()
|
|
323
|
+
self.paint()
|
|
324
|
+
|
|
325
|
+
def set_idle(self) -> None:
|
|
326
|
+
# X11 has no cheap per-window alpha here; keep mapped (visible) as session idle.
|
|
327
|
+
self.ensure_session()
|
|
328
|
+
|
|
329
|
+
def end_session(self) -> None:
|
|
330
|
+
if not self.available:
|
|
331
|
+
return
|
|
332
|
+
with self._lock:
|
|
333
|
+
if self.mapped:
|
|
334
|
+
X11.XUnmapWindow(self.dpy, self.win)
|
|
335
|
+
X11.XFlush(self.dpy)
|
|
336
|
+
self.mapped = False
|
|
337
|
+
self.has_tip = False
|
|
338
|
+
|
|
339
|
+
def _position(self) -> None:
|
|
340
|
+
x = int(self.tip[0] - HOTSPOT[0])
|
|
341
|
+
y = int(self.tip[1] - HOTSPOT[1])
|
|
342
|
+
X11.XMoveWindow(self.dpy, self.win, x, y)
|
|
343
|
+
X11.XFlush(self.dpy)
|
|
344
|
+
|
|
345
|
+
def move_tip(self, x: float, y: float) -> None:
|
|
346
|
+
if not self.available:
|
|
347
|
+
self.tip = (x, y)
|
|
348
|
+
self.has_tip = True
|
|
349
|
+
return
|
|
350
|
+
with self._lock:
|
|
351
|
+
self.tip = (x, y)
|
|
352
|
+
self.has_tip = True
|
|
353
|
+
if not self.mapped:
|
|
354
|
+
X11.XMapRaised(self.dpy, self.win)
|
|
355
|
+
self.mapped = True
|
|
356
|
+
self._position()
|
|
357
|
+
self.paint()
|
|
358
|
+
|
|
359
|
+
def flash_click(self) -> None:
|
|
360
|
+
# Brief re-paint pulse (glow only — no separate ripple timer in this helper).
|
|
361
|
+
self.paint()
|
|
362
|
+
|
|
363
|
+
def geometry(self) -> dict:
|
|
364
|
+
if not self.available:
|
|
365
|
+
# Fallback via xdotool / xdpyinfo when overlay never opened.
|
|
366
|
+
return probe_geometry_fallback()
|
|
367
|
+
return {
|
|
368
|
+
"ok": True,
|
|
369
|
+
"originX": 0,
|
|
370
|
+
"originY": 0,
|
|
371
|
+
"pointWidth": float(self.width_px),
|
|
372
|
+
"pointHeight": float(self.height_px),
|
|
373
|
+
"pixelWidth": float(self.width_px),
|
|
374
|
+
"pixelHeight": float(self.height_px),
|
|
375
|
+
"scaleFactor": 1.0,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
def pump(self) -> None:
|
|
379
|
+
if not self.available or not self.dpy:
|
|
380
|
+
return
|
|
381
|
+
# Drain expose events so the pointer redraws after obscure.
|
|
382
|
+
ev = create_string_buffer(192)
|
|
383
|
+
while X11.XPending(self.dpy) > 0:
|
|
384
|
+
X11.XNextEvent(self.dpy, ev)
|
|
385
|
+
self.paint()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def probe_geometry_fallback() -> dict:
|
|
389
|
+
if which("xdotool"):
|
|
390
|
+
try:
|
|
391
|
+
out = subprocess.check_output(
|
|
392
|
+
["xdotool", "getdisplaygeometry"], text=True, stderr=subprocess.DEVNULL
|
|
393
|
+
).strip()
|
|
394
|
+
parts = out.split()
|
|
395
|
+
if len(parts) >= 2:
|
|
396
|
+
w, h = float(parts[0]), float(parts[1])
|
|
397
|
+
return {
|
|
398
|
+
"ok": True,
|
|
399
|
+
"originX": 0,
|
|
400
|
+
"originY": 0,
|
|
401
|
+
"pointWidth": w,
|
|
402
|
+
"pointHeight": h,
|
|
403
|
+
"pixelWidth": w,
|
|
404
|
+
"pixelHeight": h,
|
|
405
|
+
"scaleFactor": 1.0,
|
|
406
|
+
}
|
|
407
|
+
except (subprocess.CalledProcessError, ValueError):
|
|
408
|
+
pass
|
|
409
|
+
return {"ok": False, "error": "no display geometry available"}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def ease_out_cubic(t: float) -> float:
|
|
413
|
+
t = min(1.0, max(0.0, t))
|
|
414
|
+
return 1.0 - (1.0 - t) ** 3
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def animate_overlay(overlay: Overlay, target: tuple[float, float]) -> None:
|
|
418
|
+
overlay.ensure_session()
|
|
419
|
+
start = overlay.tip if overlay.has_tip else target
|
|
420
|
+
dx = target[0] - start[0]
|
|
421
|
+
dy = target[1] - start[1]
|
|
422
|
+
distance = math.hypot(dx, dy)
|
|
423
|
+
if distance < 1.5:
|
|
424
|
+
overlay.move_tip(*target)
|
|
425
|
+
return
|
|
426
|
+
duration = min(0.11, max(0.028, distance * 0.00025))
|
|
427
|
+
steps = max(1, int(math.ceil(duration * 60)))
|
|
428
|
+
step_sleep = duration / steps
|
|
429
|
+
for i in range(1, steps + 1):
|
|
430
|
+
t = ease_out_cubic(i / steps)
|
|
431
|
+
overlay.move_tip(start[0] + dx * t, start[1] + dy * t)
|
|
432
|
+
overlay.pump()
|
|
433
|
+
if i < steps:
|
|
434
|
+
time.sleep(step_sleep)
|
|
435
|
+
overlay.move_tip(*target)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def run_xdotool(args: list[str]) -> None:
|
|
439
|
+
if not which("xdotool"):
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
"xdotool is required for computer use on X11. Install xdotool, or use a Wayland tool path."
|
|
442
|
+
)
|
|
443
|
+
subprocess.check_call(["xdotool", *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def cursor_position() -> tuple[int, int]:
|
|
447
|
+
out = subprocess.check_output(
|
|
448
|
+
["xdotool", "getmouselocation", "--shell"], text=True, stderr=subprocess.DEVNULL
|
|
449
|
+
)
|
|
450
|
+
vals = {}
|
|
451
|
+
for line in out.splitlines():
|
|
452
|
+
if "=" in line:
|
|
453
|
+
k, v = line.split("=", 1)
|
|
454
|
+
vals[k] = int(v)
|
|
455
|
+
return vals.get("X", 0), vals.get("Y", 0)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def with_preserved_cursor(body) -> None:
|
|
459
|
+
"""Save → act → restore so agent clicks do not strand the user pointer."""
|
|
460
|
+
if not which("xdotool"):
|
|
461
|
+
body()
|
|
462
|
+
return
|
|
463
|
+
sx, sy = cursor_position()
|
|
464
|
+
try:
|
|
465
|
+
body()
|
|
466
|
+
finally:
|
|
467
|
+
run_xdotool(["mousemove", "--sync", str(sx), str(sy)])
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def click_at(overlay: Overlay, x: float, y: float, button: str, count: int) -> None:
|
|
471
|
+
animate_overlay(overlay, (x, y))
|
|
472
|
+
overlay.flash_click()
|
|
473
|
+
btn = "3" if button == "right" else "1"
|
|
474
|
+
|
|
475
|
+
def act() -> None:
|
|
476
|
+
run_xdotool(["mousemove", "--sync", str(int(x)), str(int(y))])
|
|
477
|
+
for _ in range(count):
|
|
478
|
+
run_xdotool(["click", btn])
|
|
479
|
+
if count > 1:
|
|
480
|
+
time.sleep(0.04)
|
|
481
|
+
|
|
482
|
+
with_preserved_cursor(act)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def drag(overlay: Overlay, fx: float, fy: float, tx: float, ty: float) -> None:
|
|
486
|
+
animate_overlay(overlay, (fx, fy))
|
|
487
|
+
overlay.flash_click()
|
|
488
|
+
|
|
489
|
+
def act() -> None:
|
|
490
|
+
run_xdotool(["mousemove", "--sync", str(int(fx)), str(int(fy))])
|
|
491
|
+
run_xdotool(["mousedown", "1"])
|
|
492
|
+
distance = math.hypot(tx - fx, ty - fy)
|
|
493
|
+
steps = max(6, min(14, int(distance / 40) + 6))
|
|
494
|
+
for i in range(1, steps + 1):
|
|
495
|
+
t = ease_out_cubic(i / steps)
|
|
496
|
+
px = fx + (tx - fx) * t
|
|
497
|
+
py = fy + (ty - fy) * t
|
|
498
|
+
overlay.move_tip(px, py)
|
|
499
|
+
run_xdotool(["mousemove", "--sync", str(int(px)), str(int(py))])
|
|
500
|
+
time.sleep(0.006)
|
|
501
|
+
run_xdotool(["mouseup", "1"])
|
|
502
|
+
|
|
503
|
+
with_preserved_cursor(act)
|
|
504
|
+
overlay.move_tip(tx, ty)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def scroll_at(overlay: Overlay, x: float, y: float, direction: str, amount: int) -> None:
|
|
508
|
+
animate_overlay(overlay, (x, y))
|
|
509
|
+
# xdotool: 4=up 5=down 6=left 7=right
|
|
510
|
+
mapping = {"up": "4", "down": "5", "left": "6", "right": "7"}
|
|
511
|
+
btn = mapping[direction]
|
|
512
|
+
|
|
513
|
+
def act() -> None:
|
|
514
|
+
run_xdotool(["mousemove", "--sync", str(int(x)), str(int(y))])
|
|
515
|
+
for _ in range(max(1, amount)):
|
|
516
|
+
run_xdotool(["click", btn])
|
|
517
|
+
time.sleep(0.012)
|
|
518
|
+
|
|
519
|
+
with_preserved_cursor(act)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def type_text(text: str) -> None:
|
|
523
|
+
if which("xdotool"):
|
|
524
|
+
# --clearmodifiers avoids sticky Meta from the agent chord path.
|
|
525
|
+
subprocess.check_call(
|
|
526
|
+
["xdotool", "type", "--clearmodifiers", "--delay", "2", "--", text],
|
|
527
|
+
stdout=subprocess.DEVNULL,
|
|
528
|
+
stderr=subprocess.PIPE,
|
|
529
|
+
)
|
|
530
|
+
return
|
|
531
|
+
raise RuntimeError("xdotool is required to type text on this Linux session.")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def parse_and_post_key(key_spec: str) -> bool:
|
|
535
|
+
parts = [p.strip().lower() for p in key_spec.split("+") if p.strip()]
|
|
536
|
+
if not parts:
|
|
537
|
+
return False
|
|
538
|
+
mods = []
|
|
539
|
+
key = None
|
|
540
|
+
for part in parts:
|
|
541
|
+
if part in ("cmd", "command", "meta", "super"):
|
|
542
|
+
mods.append("super")
|
|
543
|
+
elif part in ("ctrl", "control"):
|
|
544
|
+
mods.append("ctrl")
|
|
545
|
+
elif part in ("alt", "option", "opt"):
|
|
546
|
+
mods.append("alt")
|
|
547
|
+
elif part == "shift":
|
|
548
|
+
mods.append("shift")
|
|
549
|
+
else:
|
|
550
|
+
if key is not None:
|
|
551
|
+
return False
|
|
552
|
+
key = part
|
|
553
|
+
if key is None:
|
|
554
|
+
return False
|
|
555
|
+
named = {
|
|
556
|
+
"return": "Return",
|
|
557
|
+
"enter": "Return",
|
|
558
|
+
"tab": "Tab",
|
|
559
|
+
"escape": "Escape",
|
|
560
|
+
"esc": "Escape",
|
|
561
|
+
"delete": "Delete",
|
|
562
|
+
"backspace": "BackSpace",
|
|
563
|
+
"space": "space",
|
|
564
|
+
"up": "Up",
|
|
565
|
+
"down": "Down",
|
|
566
|
+
"left": "Left",
|
|
567
|
+
"right": "Right",
|
|
568
|
+
"home": "Home",
|
|
569
|
+
"end": "End",
|
|
570
|
+
"pageup": "Page_Up",
|
|
571
|
+
"pagedown": "Page_Down",
|
|
572
|
+
}
|
|
573
|
+
if key in named:
|
|
574
|
+
keysym = named[key]
|
|
575
|
+
elif len(key) == 1:
|
|
576
|
+
keysym = key
|
|
577
|
+
elif key.startswith("f") and key[1:].isdigit() and 1 <= int(key[1:]) <= 12:
|
|
578
|
+
keysym = f"F{int(key[1:])}"
|
|
579
|
+
else:
|
|
580
|
+
return False
|
|
581
|
+
chord = "+".join([*mods, keysym])
|
|
582
|
+
run_xdotool(["key", "--clearmodifiers", chord])
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def handle(overlay: Overlay, obj: dict) -> None:
|
|
587
|
+
cmd = obj.get("cmd")
|
|
588
|
+
if not isinstance(cmd, str):
|
|
589
|
+
write_error("missing cmd")
|
|
590
|
+
return
|
|
591
|
+
try:
|
|
592
|
+
if cmd == "ping":
|
|
593
|
+
write_ok()
|
|
594
|
+
elif cmd == "geometry":
|
|
595
|
+
write_response(overlay.geometry())
|
|
596
|
+
elif cmd == "ensureSession":
|
|
597
|
+
overlay.ensure_session()
|
|
598
|
+
write_ok()
|
|
599
|
+
elif cmd == "setIdle":
|
|
600
|
+
overlay.set_idle()
|
|
601
|
+
write_ok()
|
|
602
|
+
elif cmd == "endSession":
|
|
603
|
+
overlay.end_session()
|
|
604
|
+
write_ok()
|
|
605
|
+
elif cmd == "move":
|
|
606
|
+
x, y = float(obj["pointX"]), float(obj["pointY"])
|
|
607
|
+
animate_overlay(overlay, (x, y))
|
|
608
|
+
write_ok()
|
|
609
|
+
elif cmd == "click":
|
|
610
|
+
button = str(obj.get("button", "left")).lower()
|
|
611
|
+
count = int(obj.get("count", 1))
|
|
612
|
+
tip = overlay.tip
|
|
613
|
+
click_at(overlay, tip[0], tip[1], button, count)
|
|
614
|
+
write_ok()
|
|
615
|
+
elif cmd == "clickAt":
|
|
616
|
+
x, y = float(obj["pointX"]), float(obj["pointY"])
|
|
617
|
+
button = str(obj.get("button", "left")).lower()
|
|
618
|
+
count = int(obj.get("count", 1))
|
|
619
|
+
if button not in ("left", "right") or count not in (1, 2):
|
|
620
|
+
write_error("invalid button/count")
|
|
621
|
+
return
|
|
622
|
+
click_at(overlay, x, y, button, count)
|
|
623
|
+
write_ok()
|
|
624
|
+
elif cmd == "drag":
|
|
625
|
+
drag(
|
|
626
|
+
overlay,
|
|
627
|
+
float(obj["fromX"]),
|
|
628
|
+
float(obj["fromY"]),
|
|
629
|
+
float(obj["toX"]),
|
|
630
|
+
float(obj["toY"]),
|
|
631
|
+
)
|
|
632
|
+
write_ok()
|
|
633
|
+
elif cmd == "type":
|
|
634
|
+
text = obj.get("text")
|
|
635
|
+
if not isinstance(text, str):
|
|
636
|
+
write_error("type requires text")
|
|
637
|
+
return
|
|
638
|
+
type_text(text)
|
|
639
|
+
write_ok()
|
|
640
|
+
elif cmd == "key":
|
|
641
|
+
key = obj.get("key")
|
|
642
|
+
if not isinstance(key, str):
|
|
643
|
+
write_error("key requires key")
|
|
644
|
+
return
|
|
645
|
+
if parse_and_post_key(key):
|
|
646
|
+
write_ok()
|
|
647
|
+
else:
|
|
648
|
+
write_error(f"unrecognized key: {key}")
|
|
649
|
+
elif cmd == "scroll":
|
|
650
|
+
scroll_at(
|
|
651
|
+
overlay,
|
|
652
|
+
float(obj["pointX"]),
|
|
653
|
+
float(obj["pointY"]),
|
|
654
|
+
str(obj["direction"]).lower(),
|
|
655
|
+
int(obj["amount"]),
|
|
656
|
+
)
|
|
657
|
+
write_ok()
|
|
658
|
+
else:
|
|
659
|
+
write_error(f"unknown cmd: {cmd}")
|
|
660
|
+
except KeyError as exc:
|
|
661
|
+
write_error(f"missing field: {exc}")
|
|
662
|
+
except Exception as exc: # noqa: BLE001 — surface to worker as JSON error
|
|
663
|
+
log_err(str(exc))
|
|
664
|
+
write_error(str(exc))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def main() -> int:
|
|
668
|
+
overlay = Overlay()
|
|
669
|
+
overlay.open() # may fail on Wayland — input can still work via xdotool if XWayland
|
|
670
|
+
# Stdin loop on main thread; pump X events between lines.
|
|
671
|
+
for raw in sys.stdin:
|
|
672
|
+
line = raw.strip()
|
|
673
|
+
if not line:
|
|
674
|
+
continue
|
|
675
|
+
try:
|
|
676
|
+
obj = json.loads(line)
|
|
677
|
+
except json.JSONDecodeError:
|
|
678
|
+
log_err("invalid JSON line")
|
|
679
|
+
write_error("invalid JSON")
|
|
680
|
+
continue
|
|
681
|
+
if not isinstance(obj, dict):
|
|
682
|
+
write_error("invalid JSON")
|
|
683
|
+
continue
|
|
684
|
+
handle(overlay, obj)
|
|
685
|
+
overlay.pump()
|
|
686
|
+
overlay.end_session()
|
|
687
|
+
return 0
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
if __name__ == "__main__":
|
|
691
|
+
try:
|
|
692
|
+
raise SystemExit(main())
|
|
693
|
+
except KeyboardInterrupt:
|
|
694
|
+
raise SystemExit(0)
|