@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.
@@ -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)