@remcp/runtime 0.2.152 → 0.2.154

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.
@@ -5,13 +5,14 @@ Owns the portal session and EIS file descriptor in one process so SCM_RIGHTS nev
5
5
  has to cross Node's net.Socket implementation. JSON-lines are used only between
6
6
  this helper and the local ReMCP runtime.
7
7
  """
8
- import ctypes
9
8
  import json
10
9
  import os
11
10
  import select
12
11
  import sys
13
12
  import time
14
13
 
14
+ from wayland_eis_sender import EiSender
15
+
15
16
  try:
16
17
  import gi
17
18
  gi.require_version("Gio", "2.0")
@@ -27,21 +28,6 @@ REQUEST_IFACE = "org.freedesktop.portal.Request"
27
28
  SESSION_IFACE = "org.freedesktop.portal.Session"
28
29
  DEVICE_TYPES = 1 | 2 # keyboard | pointer in the portal API
29
30
 
30
- EI_CAP_POINTER = 1 << 0
31
- EI_CAP_POINTER_ABSOLUTE = 1 << 1
32
- EI_CAP_KEYBOARD = 1 << 2
33
- EI_CAP_SCROLL = 1 << 4
34
- EI_CAP_BUTTON = 1 << 5
35
- EI_EVENT_CONNECT = 1
36
- EI_EVENT_DISCONNECT = 2
37
- EI_EVENT_SEAT_ADDED = 3
38
- EI_EVENT_SEAT_REMOVED = 4
39
- EI_EVENT_DEVICE_ADDED = 5
40
- EI_EVENT_DEVICE_REMOVED = 6
41
- EI_EVENT_DEVICE_PAUSED = 7
42
- EI_EVENT_DEVICE_RESUMED = 8
43
-
44
-
45
31
  def emit(payload):
46
32
  sys.stdout.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n")
47
33
  sys.stdout.flush()
@@ -186,329 +172,6 @@ class Portal:
186
172
  pass
187
173
 
188
174
 
189
- class EiSender:
190
- def __init__(self, fd):
191
- self.lib = ctypes.CDLL("libei.so.1")
192
- c_void_p = ctypes.c_void_p
193
- self.lib.ei_new_sender.argtypes = [c_void_p]
194
- self.lib.ei_new_sender.restype = c_void_p
195
- self.lib.ei_configure_name.argtypes = [c_void_p, ctypes.c_char_p]
196
- self.lib.ei_setup_backend_fd.argtypes = [c_void_p, ctypes.c_int]
197
- self.lib.ei_setup_backend_fd.restype = ctypes.c_int
198
- self.lib.ei_get_fd.argtypes = [c_void_p]
199
- self.lib.ei_get_fd.restype = ctypes.c_int
200
- self.lib.ei_dispatch.argtypes = [c_void_p]
201
- self.lib.ei_get_event.argtypes = [c_void_p]
202
- self.lib.ei_get_event.restype = c_void_p
203
- self.lib.ei_event_get_type.argtypes = [c_void_p]
204
- self.lib.ei_event_get_type.restype = ctypes.c_int
205
- self.lib.ei_event_get_seat.argtypes = [c_void_p]
206
- self.lib.ei_event_get_seat.restype = c_void_p
207
- self.lib.ei_event_get_device.argtypes = [c_void_p]
208
- self.lib.ei_event_get_device.restype = c_void_p
209
- self.lib.ei_event_unref.argtypes = [c_void_p]
210
- self.lib.ei_event_unref.restype = c_void_p
211
- self.lib.ei_device_ref.argtypes = [c_void_p]
212
- self.lib.ei_device_ref.restype = c_void_p
213
- self.lib.ei_device_unref.argtypes = [c_void_p]
214
- self.lib.ei_device_unref.restype = c_void_p
215
- self.lib.ei_device_has_capability.argtypes = [c_void_p, ctypes.c_int]
216
- self.lib.ei_device_has_capability.restype = ctypes.c_bool
217
- self.lib.ei_device_get_region_at.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
218
- self.lib.ei_device_get_region_at.restype = c_void_p
219
- self.lib.ei_region_convert_point.argtypes = [c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
220
- self.lib.ei_region_convert_point.restype = ctypes.c_bool
221
- self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
222
- self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
223
- self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
224
- self.lib.ei_device_pointer_motion.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
225
- self.lib.ei_device_pointer_motion_absolute.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
226
- self.lib.ei_device_button_button.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
227
- self.lib.ei_device_scroll_discrete.argtypes = [c_void_p, ctypes.c_int32, ctypes.c_int32]
228
- self.lib.ei_device_frame.argtypes = [c_void_p, ctypes.c_uint64]
229
- self.lib.ei_now.argtypes = [c_void_p]
230
- self.lib.ei_now.restype = ctypes.c_uint64
231
- self.lib.ei_unref.argtypes = [c_void_p]
232
- self.lib.ei_unref.restype = c_void_p
233
- self.lib.ei_seat_has_capability.argtypes = [c_void_p, ctypes.c_int]
234
- self.lib.ei_seat_has_capability.restype = ctypes.c_bool
235
- # ei_seat_bind_capabilities is varargs: keep argtypes unset.
236
- self.lib.ei_seat_bind_capabilities.restype = None
237
-
238
- self.ei = self.lib.ei_new_sender(None)
239
- if not self.ei:
240
- raise RuntimeError("ei_new_sender failed")
241
- self.lib.ei_configure_name(self.ei, b"ReMCP")
242
- rc = int(self.lib.ei_setup_backend_fd(self.ei, fd))
243
- if rc < 0:
244
- raise RuntimeError(f"ei_setup_backend_fd failed ({rc})")
245
- self.fd = int(self.lib.ei_get_fd(self.ei))
246
- if self.fd < 0:
247
- raise RuntimeError("ei_get_fd failed")
248
- self.devices = {}
249
- self.sequence = 1
250
- self.connected = False
251
- self.pointer_device_key = None
252
- self.pointer_position = None
253
- # A press/release pair must stay on the same emulated device even if
254
- # absolute motion crosses an EIS region and changes pointer_device_key.
255
- self.button_device_keys = {}
256
-
257
- def _device_key(self, ptr):
258
- return int(ptr or 0)
259
-
260
- def _remember_device(self, ptr):
261
- key = self._device_key(ptr)
262
- if not key:
263
- return None
264
- if key not in self.devices:
265
- retained = self.lib.ei_device_ref(ptr)
266
- self.devices[key] = {"ptr": retained, "resumed": False, "emulating": False}
267
- return self.devices[key]
268
-
269
- def _drop_device(self, ptr):
270
- key = self._device_key(ptr)
271
- state = self.devices.pop(key, None)
272
- if key == self.pointer_device_key:
273
- self.pointer_device_key = None
274
- self.pointer_position = None
275
- for button, device_key in list(self.button_device_keys.items()):
276
- if device_key == key:
277
- self.button_device_keys.pop(button, None)
278
- if state:
279
- if state["emulating"]:
280
- try:
281
- self.lib.ei_device_stop_emulating(state["ptr"])
282
- except Exception:
283
- pass
284
- self.lib.ei_device_unref(state["ptr"])
285
-
286
- def _bind_seat(self, seat):
287
- caps = [EI_CAP_POINTER, EI_CAP_POINTER_ABSOLUTE, EI_CAP_KEYBOARD, EI_CAP_SCROLL, EI_CAP_BUTTON]
288
- args = [ctypes.c_void_p(seat)]
289
- args.extend(ctypes.c_int(cap) for cap in caps if self.lib.ei_seat_has_capability(seat, cap))
290
- args.append(ctypes.c_void_p())
291
- self.lib.ei_seat_bind_capabilities(*args)
292
-
293
- def process_events(self):
294
- self.lib.ei_dispatch(self.ei)
295
- while True:
296
- event = self.lib.ei_get_event(self.ei)
297
- if not event:
298
- break
299
- try:
300
- event_type = int(self.lib.ei_event_get_type(event))
301
- if event_type == EI_EVENT_CONNECT:
302
- self.connected = True
303
- elif event_type == EI_EVENT_DISCONNECT:
304
- raise RuntimeError("EIS server disconnected")
305
- elif event_type == EI_EVENT_SEAT_ADDED:
306
- seat = self.lib.ei_event_get_seat(event)
307
- if seat:
308
- self._bind_seat(seat)
309
- elif event_type == EI_EVENT_DEVICE_ADDED:
310
- device = self.lib.ei_event_get_device(event)
311
- if device:
312
- self._remember_device(device)
313
- elif event_type == EI_EVENT_DEVICE_RESUMED:
314
- device = self.lib.ei_event_get_device(event)
315
- state = self._remember_device(device)
316
- if state:
317
- state["resumed"] = True
318
- if not state["emulating"]:
319
- self.lib.ei_device_start_emulating(state["ptr"], self.sequence)
320
- self.sequence = (self.sequence + 1) & 0xFFFFFFFF or 1
321
- state["emulating"] = True
322
- elif event_type == EI_EVENT_DEVICE_PAUSED:
323
- device = self.lib.ei_event_get_device(event)
324
- key = self._device_key(device)
325
- state = self.devices.get(key)
326
- if state:
327
- state["resumed"] = False
328
- state["emulating"] = False
329
- for button, device_key in list(self.button_device_keys.items()):
330
- if device_key == key:
331
- self.button_device_keys.pop(button, None)
332
- elif event_type == EI_EVENT_DEVICE_REMOVED:
333
- self._drop_device(self.lib.ei_event_get_device(event))
334
- finally:
335
- self.lib.ei_event_unref(event)
336
-
337
- def wait_ready(self, timeout_s=10.0):
338
- deadline = time.monotonic() + timeout_s
339
- while time.monotonic() < deadline:
340
- if (
341
- self.connected
342
- and self.capability_ready(EI_CAP_KEYBOARD)
343
- and self.capability_ready(EI_CAP_POINTER)
344
- and self.capability_ready(EI_CAP_BUTTON)
345
- and self.capability_ready(EI_CAP_SCROLL)
346
- ):
347
- return
348
- readable, _, _ = select.select([self.fd], [], [], min(0.1, max(0.0, deadline - time.monotonic())))
349
- if readable:
350
- self.process_events()
351
- raise TimeoutError(f"EIS devices did not become ready; capabilities={self.capabilities()}")
352
-
353
- def capability_ready(self, cap):
354
- return any(state["resumed"] and self.lib.ei_device_has_capability(state["ptr"], cap) for state in self.devices.values())
355
-
356
- def capabilities(self):
357
- names = {
358
- EI_CAP_POINTER: "pointer",
359
- EI_CAP_POINTER_ABSOLUTE: "pointer_absolute",
360
- EI_CAP_KEYBOARD: "keyboard",
361
- EI_CAP_SCROLL: "scroll",
362
- EI_CAP_BUTTON: "button",
363
- }
364
- return {name: self.capability_ready(cap) for cap, name in names.items()}
365
-
366
- def device_for(self, cap):
367
- for state in self.devices.values():
368
- if state["resumed"] and state["emulating"] and self.lib.ei_device_has_capability(state["ptr"], cap):
369
- return state["ptr"]
370
- raise RuntimeError(f"no resumed EIS device with capability {cap}")
371
-
372
- def _absolute_motion_for_device(self, device, x, y):
373
- if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
374
- return None
375
- region = self.lib.ei_device_get_region_at(device, float(x), float(y))
376
- if not region:
377
- return None
378
- motion_x = ctypes.c_double(float(x))
379
- motion_y = ctypes.c_double(float(y))
380
- if not self.lib.ei_region_convert_point(region, ctypes.byref(motion_x), ctypes.byref(motion_y)):
381
- raise RuntimeError(f"failed to convert desktop coordinate ({x}, {y}) into the selected EIS region")
382
- return motion_x.value, motion_y.value
383
-
384
- def device_for_absolute(self, x, y):
385
- candidates = []
386
-
387
- # Preserve pointer identity across absolute motion whenever the current
388
- # device still covers the destination. This is required for pointer
389
- # capture during drags: switching EIS devices mid-gesture can drop the
390
- # captured pointer before its button release is observed.
391
- preferred = self.devices.get(self.pointer_device_key)
392
- if preferred and preferred["resumed"] and preferred["emulating"]:
393
- device = preferred["ptr"]
394
- if self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
395
- candidates.append(device)
396
- converted = self._absolute_motion_for_device(device, x, y)
397
- if converted is not None:
398
- return device, converted[0], converted[1]
399
-
400
- for state in self.devices.values():
401
- if not state["resumed"] or not state["emulating"]:
402
- continue
403
- device = state["ptr"]
404
- if preferred and self._device_key(device) == self.pointer_device_key:
405
- continue
406
- if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
407
- continue
408
- candidates.append(device)
409
- converted = self._absolute_motion_for_device(device, x, y)
410
- if converted is not None:
411
- return device, converted[0], converted[1]
412
- if candidates:
413
- raise RuntimeError(f"no absolute-pointer EIS region contains desktop coordinate ({x}, {y})")
414
- raise RuntimeError("no resumed EIS device with absolute-pointer capability")
415
-
416
- def device_for_pointer_context(self, cap):
417
- state = self.devices.get(self.pointer_device_key)
418
- if (
419
- state
420
- and state["resumed"]
421
- and state["emulating"]
422
- and self.lib.ei_device_has_capability(state["ptr"], cap)
423
- ):
424
- return state["ptr"]
425
- if self.pointer_position is not None:
426
- x, y = self.pointer_position
427
- for candidate in self.devices.values():
428
- if not candidate["resumed"] or not candidate["emulating"]:
429
- continue
430
- device = candidate["ptr"]
431
- if not self.lib.ei_device_has_capability(device, cap):
432
- continue
433
- if self.lib.ei_device_get_region_at(device, float(x), float(y)):
434
- return device
435
- return self.device_for(cap)
436
-
437
- def remember_pointer(self, device, position=None):
438
- self.pointer_device_key = self._device_key(device)
439
- self.pointer_position = position
440
-
441
- def frame(self, device):
442
- self.lib.ei_device_frame(device, self.lib.ei_now(self.ei))
443
-
444
- def command(self, payload):
445
- op = str(payload.get("op") or "")
446
- if op == "key":
447
- device = self.device_for(EI_CAP_KEYBOARD)
448
- self.lib.ei_device_keyboard_key(device, int(payload["keycode"]), bool(payload.get("pressed")))
449
- self.frame(device)
450
- elif op == "motion":
451
- device = self.device_for(EI_CAP_POINTER)
452
- self.lib.ei_device_pointer_motion(device, float(payload.get("dx", 0)), float(payload.get("dy", 0)))
453
- self.remember_pointer(device)
454
- self.frame(device)
455
- elif op == "motion_absolute":
456
- x = float(payload["x"])
457
- y = float(payload["y"])
458
- device, motion_x, motion_y = self.device_for_absolute(x, y)
459
- self.lib.ei_device_pointer_motion_absolute(device, motion_x, motion_y)
460
- self.remember_pointer(device, (x, y))
461
- self.frame(device)
462
- elif op == "button":
463
- button = int(payload["button"])
464
- pressed = bool(payload.get("pressed"))
465
- if pressed:
466
- device = self.device_for_pointer_context(EI_CAP_BUTTON)
467
- self.button_device_keys[button] = self._device_key(device)
468
- else:
469
- key = self.button_device_keys.pop(button, None)
470
- state = self.devices.get(key) if key else None
471
- if (
472
- state
473
- and state["resumed"]
474
- and state["emulating"]
475
- and self.lib.ei_device_has_capability(state["ptr"], EI_CAP_BUTTON)
476
- ):
477
- device = state["ptr"]
478
- else:
479
- device = self.device_for_pointer_context(EI_CAP_BUTTON)
480
- self.lib.ei_device_button_button(device, button, pressed)
481
- self.frame(device)
482
- elif op == "scroll":
483
- device = self.device_for_pointer_context(EI_CAP_SCROLL)
484
- self.lib.ei_device_scroll_discrete(device, int(payload.get("dx", 0)), int(payload.get("dy", 0)))
485
- self.frame(device)
486
- elif op == "ping":
487
- pass
488
- elif op == "close":
489
- return False
490
- else:
491
- raise ValueError(f"unsupported EIS operation: {op}")
492
- return True
493
-
494
- def close(self):
495
- for state in list(self.devices.values()):
496
- if state["emulating"]:
497
- try:
498
- self.lib.ei_device_stop_emulating(state["ptr"])
499
- except Exception:
500
- pass
501
- try:
502
- self.lib.ei_device_unref(state["ptr"])
503
- except Exception:
504
- pass
505
- self.devices.clear()
506
- self.button_device_keys.clear()
507
- if self.ei:
508
- self.lib.ei_unref(self.ei)
509
- self.ei = None
510
-
511
-
512
175
  def main():
513
176
  timeout_ms = max(1000, min(120000, int(os.environ.get("REMCP_EIS_TIMEOUT_MS", "30000"))))
514
177
  restore_token = os.environ.get("REMCP_EIS_RESTORE_TOKEN", "")