@jian-ai/remote-control 0.8.1 → 0.8.3

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.
@@ -0,0 +1,695 @@
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 white pointer + #2880F8 stroke/glow (parity with macOS sprite).
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"#FFFFFF", byref(white), byref(exact)):
283
+ X11.XSetForeground(self.dpy, self.gc, white.pixel)
284
+ glow = XColor()
285
+ # Match macOS ship sprite stroke #2880F8 (geometric stand-in; bitmap is macOS-only).
286
+ if X11.XAllocNamedColor(self.dpy, cmap, b"#2880F8", byref(glow), byref(exact)):
287
+ X11.XSetForeground(self.dpy, self.gc_glow, glow.pixel)
288
+ ink = XColor()
289
+ if X11.XAllocNamedColor(self.dpy, cmap, b"#2880F8", byref(ink), byref(exact)):
290
+ X11.XSetForeground(self.dpy, self.gc_ink, ink.pixel)
291
+ self.available = True
292
+ return True
293
+
294
+ def _points(self, offset=(0, 0)) -> list[XPoint]:
295
+ ox, oy = offset
296
+ return [XPoint(x=int(x + ox), y=int(y + oy)) for x, y in ARROW]
297
+
298
+ def paint(self) -> None:
299
+ if not self.available or not self.mapped:
300
+ return
301
+ X11.XClearWindow(self.dpy, self.win)
302
+ # Soft “glow”: fill the arrow slightly offset in brand blue, then white on top.
303
+ glow_pts = (XPoint * len(ARROW))(*self._points((1, 2)))
304
+ X11.XFillPolygon(self.dpy, self.win, self.gc_glow, glow_pts, len(ARROW), Complex, CoordModeOrigin)
305
+ pts = (XPoint * len(ARROW))(*self._points())
306
+ X11.XFillPolygon(self.dpy, self.win, self.gc, pts, len(ARROW), Complex, CoordModeOrigin)
307
+ X11.XDrawLines(self.dpy, self.win, self.gc_ink, pts, len(ARROW), CoordModeOrigin)
308
+ # Close the polygon stroke.
309
+ close = (XPoint * 2)(pts[len(ARROW) - 1], pts[0])
310
+ X11.XDrawLines(self.dpy, self.win, self.gc_ink, close, 2, CoordModeOrigin)
311
+ X11.XFlush(self.dpy)
312
+
313
+ def ensure_session(self) -> None:
314
+ if not self.available:
315
+ return
316
+ with self._lock:
317
+ if not self.has_tip:
318
+ self.tip = (self.width_px / 2, self.height_px / 2)
319
+ self.has_tip = True
320
+ if not self.mapped:
321
+ X11.XMapRaised(self.dpy, self.win)
322
+ self.mapped = True
323
+ self._position()
324
+ self.paint()
325
+
326
+ def set_idle(self) -> None:
327
+ # X11 has no cheap per-window alpha here; keep mapped (visible) as session idle.
328
+ self.ensure_session()
329
+
330
+ def end_session(self) -> None:
331
+ if not self.available:
332
+ return
333
+ with self._lock:
334
+ if self.mapped:
335
+ X11.XUnmapWindow(self.dpy, self.win)
336
+ X11.XFlush(self.dpy)
337
+ self.mapped = False
338
+ self.has_tip = False
339
+
340
+ def _position(self) -> None:
341
+ x = int(self.tip[0] - HOTSPOT[0])
342
+ y = int(self.tip[1] - HOTSPOT[1])
343
+ X11.XMoveWindow(self.dpy, self.win, x, y)
344
+ X11.XFlush(self.dpy)
345
+
346
+ def move_tip(self, x: float, y: float) -> None:
347
+ if not self.available:
348
+ self.tip = (x, y)
349
+ self.has_tip = True
350
+ return
351
+ with self._lock:
352
+ self.tip = (x, y)
353
+ self.has_tip = True
354
+ if not self.mapped:
355
+ X11.XMapRaised(self.dpy, self.win)
356
+ self.mapped = True
357
+ self._position()
358
+ self.paint()
359
+
360
+ def flash_click(self) -> None:
361
+ # Brief re-paint pulse (glow only — no separate ripple timer in this helper).
362
+ self.paint()
363
+
364
+ def geometry(self) -> dict:
365
+ if not self.available:
366
+ # Fallback via xdotool / xdpyinfo when overlay never opened.
367
+ return probe_geometry_fallback()
368
+ return {
369
+ "ok": True,
370
+ "originX": 0,
371
+ "originY": 0,
372
+ "pointWidth": float(self.width_px),
373
+ "pointHeight": float(self.height_px),
374
+ "pixelWidth": float(self.width_px),
375
+ "pixelHeight": float(self.height_px),
376
+ "scaleFactor": 1.0,
377
+ }
378
+
379
+ def pump(self) -> None:
380
+ if not self.available or not self.dpy:
381
+ return
382
+ # Drain expose events so the pointer redraws after obscure.
383
+ ev = create_string_buffer(192)
384
+ while X11.XPending(self.dpy) > 0:
385
+ X11.XNextEvent(self.dpy, ev)
386
+ self.paint()
387
+
388
+
389
+ def probe_geometry_fallback() -> dict:
390
+ if which("xdotool"):
391
+ try:
392
+ out = subprocess.check_output(
393
+ ["xdotool", "getdisplaygeometry"], text=True, stderr=subprocess.DEVNULL
394
+ ).strip()
395
+ parts = out.split()
396
+ if len(parts) >= 2:
397
+ w, h = float(parts[0]), float(parts[1])
398
+ return {
399
+ "ok": True,
400
+ "originX": 0,
401
+ "originY": 0,
402
+ "pointWidth": w,
403
+ "pointHeight": h,
404
+ "pixelWidth": w,
405
+ "pixelHeight": h,
406
+ "scaleFactor": 1.0,
407
+ }
408
+ except (subprocess.CalledProcessError, ValueError):
409
+ pass
410
+ return {"ok": False, "error": "no display geometry available"}
411
+
412
+
413
+ def ease_out_cubic(t: float) -> float:
414
+ t = min(1.0, max(0.0, t))
415
+ return 1.0 - (1.0 - t) ** 3
416
+
417
+
418
+ def animate_overlay(overlay: Overlay, target: tuple[float, float]) -> None:
419
+ overlay.ensure_session()
420
+ start = overlay.tip if overlay.has_tip else target
421
+ dx = target[0] - start[0]
422
+ dy = target[1] - start[1]
423
+ distance = math.hypot(dx, dy)
424
+ if distance < 1.5:
425
+ overlay.move_tip(*target)
426
+ return
427
+ duration = min(0.11, max(0.028, distance * 0.00025))
428
+ steps = max(1, int(math.ceil(duration * 60)))
429
+ step_sleep = duration / steps
430
+ for i in range(1, steps + 1):
431
+ t = ease_out_cubic(i / steps)
432
+ overlay.move_tip(start[0] + dx * t, start[1] + dy * t)
433
+ overlay.pump()
434
+ if i < steps:
435
+ time.sleep(step_sleep)
436
+ overlay.move_tip(*target)
437
+
438
+
439
+ def run_xdotool(args: list[str]) -> None:
440
+ if not which("xdotool"):
441
+ raise RuntimeError(
442
+ "xdotool is required for computer use on X11. Install xdotool, or use a Wayland tool path."
443
+ )
444
+ subprocess.check_call(["xdotool", *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
445
+
446
+
447
+ def cursor_position() -> tuple[int, int]:
448
+ out = subprocess.check_output(
449
+ ["xdotool", "getmouselocation", "--shell"], text=True, stderr=subprocess.DEVNULL
450
+ )
451
+ vals = {}
452
+ for line in out.splitlines():
453
+ if "=" in line:
454
+ k, v = line.split("=", 1)
455
+ vals[k] = int(v)
456
+ return vals.get("X", 0), vals.get("Y", 0)
457
+
458
+
459
+ def with_preserved_cursor(body) -> None:
460
+ """Save → act → restore so agent clicks do not strand the user pointer."""
461
+ if not which("xdotool"):
462
+ body()
463
+ return
464
+ sx, sy = cursor_position()
465
+ try:
466
+ body()
467
+ finally:
468
+ run_xdotool(["mousemove", "--sync", str(sx), str(sy)])
469
+
470
+
471
+ def click_at(overlay: Overlay, x: float, y: float, button: str, count: int) -> None:
472
+ animate_overlay(overlay, (x, y))
473
+ overlay.flash_click()
474
+ btn = "3" if button == "right" else "1"
475
+
476
+ def act() -> None:
477
+ run_xdotool(["mousemove", "--sync", str(int(x)), str(int(y))])
478
+ for _ in range(count):
479
+ run_xdotool(["click", btn])
480
+ if count > 1:
481
+ time.sleep(0.04)
482
+
483
+ with_preserved_cursor(act)
484
+
485
+
486
+ def drag(overlay: Overlay, fx: float, fy: float, tx: float, ty: float) -> None:
487
+ animate_overlay(overlay, (fx, fy))
488
+ overlay.flash_click()
489
+
490
+ def act() -> None:
491
+ run_xdotool(["mousemove", "--sync", str(int(fx)), str(int(fy))])
492
+ run_xdotool(["mousedown", "1"])
493
+ distance = math.hypot(tx - fx, ty - fy)
494
+ steps = max(6, min(14, int(distance / 40) + 6))
495
+ for i in range(1, steps + 1):
496
+ t = ease_out_cubic(i / steps)
497
+ px = fx + (tx - fx) * t
498
+ py = fy + (ty - fy) * t
499
+ overlay.move_tip(px, py)
500
+ run_xdotool(["mousemove", "--sync", str(int(px)), str(int(py))])
501
+ time.sleep(0.006)
502
+ run_xdotool(["mouseup", "1"])
503
+
504
+ with_preserved_cursor(act)
505
+ overlay.move_tip(tx, ty)
506
+
507
+
508
+ def scroll_at(overlay: Overlay, x: float, y: float, direction: str, amount: int) -> None:
509
+ animate_overlay(overlay, (x, y))
510
+ # xdotool: 4=up 5=down 6=left 7=right
511
+ mapping = {"up": "4", "down": "5", "left": "6", "right": "7"}
512
+ btn = mapping[direction]
513
+
514
+ def act() -> None:
515
+ run_xdotool(["mousemove", "--sync", str(int(x)), str(int(y))])
516
+ for _ in range(max(1, amount)):
517
+ run_xdotool(["click", btn])
518
+ time.sleep(0.012)
519
+
520
+ with_preserved_cursor(act)
521
+
522
+
523
+ def type_text(text: str) -> None:
524
+ if which("xdotool"):
525
+ # --clearmodifiers avoids sticky Meta from the agent chord path.
526
+ subprocess.check_call(
527
+ ["xdotool", "type", "--clearmodifiers", "--delay", "2", "--", text],
528
+ stdout=subprocess.DEVNULL,
529
+ stderr=subprocess.PIPE,
530
+ )
531
+ return
532
+ raise RuntimeError("xdotool is required to type text on this Linux session.")
533
+
534
+
535
+ def parse_and_post_key(key_spec: str) -> bool:
536
+ parts = [p.strip().lower() for p in key_spec.split("+") if p.strip()]
537
+ if not parts:
538
+ return False
539
+ mods = []
540
+ key = None
541
+ for part in parts:
542
+ if part in ("cmd", "command", "meta", "super"):
543
+ mods.append("super")
544
+ elif part in ("ctrl", "control"):
545
+ mods.append("ctrl")
546
+ elif part in ("alt", "option", "opt"):
547
+ mods.append("alt")
548
+ elif part == "shift":
549
+ mods.append("shift")
550
+ else:
551
+ if key is not None:
552
+ return False
553
+ key = part
554
+ if key is None:
555
+ return False
556
+ named = {
557
+ "return": "Return",
558
+ "enter": "Return",
559
+ "tab": "Tab",
560
+ "escape": "Escape",
561
+ "esc": "Escape",
562
+ "delete": "Delete",
563
+ "backspace": "BackSpace",
564
+ "space": "space",
565
+ "up": "Up",
566
+ "down": "Down",
567
+ "left": "Left",
568
+ "right": "Right",
569
+ "home": "Home",
570
+ "end": "End",
571
+ "pageup": "Page_Up",
572
+ "pagedown": "Page_Down",
573
+ }
574
+ if key in named:
575
+ keysym = named[key]
576
+ elif len(key) == 1:
577
+ keysym = key
578
+ elif key.startswith("f") and key[1:].isdigit() and 1 <= int(key[1:]) <= 12:
579
+ keysym = f"F{int(key[1:])}"
580
+ else:
581
+ return False
582
+ chord = "+".join([*mods, keysym])
583
+ run_xdotool(["key", "--clearmodifiers", chord])
584
+ return True
585
+
586
+
587
+ def handle(overlay: Overlay, obj: dict) -> None:
588
+ cmd = obj.get("cmd")
589
+ if not isinstance(cmd, str):
590
+ write_error("missing cmd")
591
+ return
592
+ try:
593
+ if cmd == "ping":
594
+ write_ok()
595
+ elif cmd == "geometry":
596
+ write_response(overlay.geometry())
597
+ elif cmd == "ensureSession":
598
+ overlay.ensure_session()
599
+ write_ok()
600
+ elif cmd == "setIdle":
601
+ overlay.set_idle()
602
+ write_ok()
603
+ elif cmd == "endSession":
604
+ overlay.end_session()
605
+ write_ok()
606
+ elif cmd == "move":
607
+ x, y = float(obj["pointX"]), float(obj["pointY"])
608
+ animate_overlay(overlay, (x, y))
609
+ write_ok()
610
+ elif cmd == "click":
611
+ button = str(obj.get("button", "left")).lower()
612
+ count = int(obj.get("count", 1))
613
+ tip = overlay.tip
614
+ click_at(overlay, tip[0], tip[1], button, count)
615
+ write_ok()
616
+ elif cmd == "clickAt":
617
+ x, y = float(obj["pointX"]), float(obj["pointY"])
618
+ button = str(obj.get("button", "left")).lower()
619
+ count = int(obj.get("count", 1))
620
+ if button not in ("left", "right") or count not in (1, 2):
621
+ write_error("invalid button/count")
622
+ return
623
+ click_at(overlay, x, y, button, count)
624
+ write_ok()
625
+ elif cmd == "drag":
626
+ drag(
627
+ overlay,
628
+ float(obj["fromX"]),
629
+ float(obj["fromY"]),
630
+ float(obj["toX"]),
631
+ float(obj["toY"]),
632
+ )
633
+ write_ok()
634
+ elif cmd == "type":
635
+ text = obj.get("text")
636
+ if not isinstance(text, str):
637
+ write_error("type requires text")
638
+ return
639
+ type_text(text)
640
+ write_ok()
641
+ elif cmd == "key":
642
+ key = obj.get("key")
643
+ if not isinstance(key, str):
644
+ write_error("key requires key")
645
+ return
646
+ if parse_and_post_key(key):
647
+ write_ok()
648
+ else:
649
+ write_error(f"unrecognized key: {key}")
650
+ elif cmd == "scroll":
651
+ scroll_at(
652
+ overlay,
653
+ float(obj["pointX"]),
654
+ float(obj["pointY"]),
655
+ str(obj["direction"]).lower(),
656
+ int(obj["amount"]),
657
+ )
658
+ write_ok()
659
+ else:
660
+ write_error(f"unknown cmd: {cmd}")
661
+ except KeyError as exc:
662
+ write_error(f"missing field: {exc}")
663
+ except Exception as exc: # noqa: BLE001 — surface to worker as JSON error
664
+ log_err(str(exc))
665
+ write_error(str(exc))
666
+
667
+
668
+ def main() -> int:
669
+ overlay = Overlay()
670
+ overlay.open() # may fail on Wayland — input can still work via xdotool if XWayland
671
+ # Stdin loop on main thread; pump X events between lines.
672
+ for raw in sys.stdin:
673
+ line = raw.strip()
674
+ if not line:
675
+ continue
676
+ try:
677
+ obj = json.loads(line)
678
+ except json.JSONDecodeError:
679
+ log_err("invalid JSON line")
680
+ write_error("invalid JSON")
681
+ continue
682
+ if not isinstance(obj, dict):
683
+ write_error("invalid JSON")
684
+ continue
685
+ handle(overlay, obj)
686
+ overlay.pump()
687
+ overlay.end_session()
688
+ return 0
689
+
690
+
691
+ if __name__ == "__main__":
692
+ try:
693
+ raise SystemExit(main())
694
+ except KeyboardInterrupt:
695
+ raise SystemExit(0)
Binary file