Files
gnome-jarvis/computer-use/py/libei_sender.py
T
snxraven 599bfe440d
Rolling release / release (push) Failing after 1m46s
Computer Use Updates
2026-09-13 19:30:17 -04:00

348 lines
14 KiB
Python

"""libei sender for an EIS fd from xdg-desktop-portal ConnectToEIS."""
import ctypes
import select
import time
# libei.h: enum ei_event_type starts at 1, then increments.
EI_EVENT_CONNECT = 1
EI_EVENT_DISCONNECT = 2
EI_EVENT_SEAT_ADDED = 3
EI_EVENT_SEAT_REMOVED = 4
EI_EVENT_DEVICE_ADDED = 5
EI_EVENT_DEVICE_REMOVED = 6
EI_EVENT_DEVICE_PAUSED = 7
EI_EVENT_DEVICE_RESUMED = 8
# libei.h: enum ei_device_capability values are bitmasks, not 1..n.
CAP_POINTER = 1 << 0
CAP_POINTER_ABSOLUTE = 1 << 1
CAP_KEYBOARD = 1 << 2
CAP_TOUCH = 1 << 3
CAP_SCROLL = 1 << 4
CAP_BUTTON = 1 << 5
BIND_CAPS = (CAP_POINTER, CAP_POINTER_ABSOLUTE, CAP_KEYBOARD, CAP_BUTTON, CAP_SCROLL)
BTN_LEFT = 0x110
BTN_RIGHT = 0x111
BTN_MIDDLE = 0x112
KEY_ESC = 1
KEY_ENTER = 28
KEY_LEFTCTRL = 29
KEY_LEFTSHIFT = 42
KEY_LEFTALT = 56
KEY_SPACE = 57
KEY_F1 = 59
KEY_BACKSPACE = 14
KEY_TAB = 15
KEY_LEFTMETA = 125
F_KEYS = {
'f1': 59, 'f2': 60, 'f3': 61, 'f4': 62, 'f5': 63, 'f6': 64, 'f7': 65, 'f8': 66,
'f9': 67, 'f10': 68, 'f11': 87, 'f12': 88,
}
COMBO_KEYS = {
'enter': KEY_ENTER, 'return': KEY_ENTER, 'esc': KEY_ESC, 'escape': KEY_ESC,
'backspace': KEY_BACKSPACE, 'tab': KEY_TAB, 'space': KEY_SPACE,
'ctrl': KEY_LEFTCTRL, 'control': KEY_LEFTCTRL, 'alt': KEY_LEFTALT,
'shift': KEY_LEFTSHIFT, 'super': KEY_LEFTMETA, 'meta': KEY_LEFTMETA,
'left': 105, 'right': 106, 'up': 103, 'down': 108,
'home': 102, 'end': 107, 'pageup': 104, 'pagedown': 109, 'delete': 111, 'insert': 110,
**F_KEYS,
}
LETTER_KEYS = {ch: code for ch, code in zip('qwertyuiopasdfghjklzxcvbnm', [
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 30, 31, 32, 33, 34, 35, 36, 37, 38, 44, 45, 46, 47, 48, 49,
])}
DIGIT_KEYS = {str(d): code for d, code in enumerate([11, 2, 3, 4, 5, 6, 7, 8, 9, 10])}
PUNCT_KEYS = {
**{ch: (DIGIT_KEYS[d], True) for ch, d in zip('!@#$%^&*()', '1234567890')},
'-': (12, False), '_': (12, True), '=': (13, False), '+': (13, True),
'[': (26, False), '{': (26, True), ']': (27, False), '}': (27, True),
';': (39, False), ':': (39, True), "'": (40, False), '"': (40, True),
'`': (41, False), '~': (41, True), '\\': (43, False), '|': (43, True),
',': (51, False), '<': (51, True), '.': (52, False), '>': (52, True),
'/': (53, False), '?': (53, True),
}
def _ptr(value):
return int(value) if value else 0
def _lib():
lib = ctypes.CDLL('libei.so.1')
lib.ei_new_sender.restype = ctypes.c_void_p
lib.ei_new_sender.argtypes = [ctypes.c_void_p]
lib.ei_configure_name.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
lib.ei_setup_backend_fd.argtypes = [ctypes.c_void_p, ctypes.c_int]
lib.ei_setup_backend_fd.restype = ctypes.c_int
lib.ei_get_fd.argtypes = [ctypes.c_void_p]
lib.ei_get_fd.restype = ctypes.c_int
lib.ei_dispatch.argtypes = [ctypes.c_void_p]
lib.ei_get_event.argtypes = [ctypes.c_void_p]
lib.ei_get_event.restype = ctypes.c_void_p
lib.ei_event_get_type.argtypes = [ctypes.c_void_p]
lib.ei_event_get_type.restype = ctypes.c_int
lib.ei_event_get_seat.argtypes = [ctypes.c_void_p]
lib.ei_event_get_seat.restype = ctypes.c_void_p
lib.ei_event_get_device.argtypes = [ctypes.c_void_p]
lib.ei_event_get_device.restype = ctypes.c_void_p
lib.ei_event_unref.argtypes = [ctypes.c_void_p]
lib.ei_seat_bind_capabilities.argtypes = [ctypes.c_void_p]
lib.ei_seat_bind_capabilities.restype = None
lib.ei_seat_has_capability.argtypes = [ctypes.c_void_p, ctypes.c_int]
lib.ei_seat_has_capability.restype = ctypes.c_int
lib.ei_device_has_capability.argtypes = [ctypes.c_void_p, ctypes.c_int]
lib.ei_device_has_capability.restype = ctypes.c_int
lib.ei_device_ref.argtypes = [ctypes.c_void_p]
lib.ei_device_ref.restype = ctypes.c_void_p
lib.ei_device_unref.argtypes = [ctypes.c_void_p]
lib.ei_device_start_emulating.argtypes = [ctypes.c_void_p, ctypes.c_uint]
lib.ei_device_stop_emulating.argtypes = [ctypes.c_void_p]
lib.ei_device_pointer_motion_absolute.argtypes = [ctypes.c_void_p, ctypes.c_double, ctypes.c_double]
lib.ei_device_pointer_motion.argtypes = [ctypes.c_void_p, ctypes.c_double, ctypes.c_double]
lib.ei_device_button_button.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_bool]
lib.ei_device_scroll_discrete.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int]
lib.ei_device_keyboard_key.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_bool]
lib.ei_device_frame.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
lib.ei_now.argtypes = [ctypes.c_void_p]
lib.ei_now.restype = ctypes.c_uint64
lib.ei_unref.argtypes = [ctypes.c_void_p]
return lib
class LibeiSender:
def __init__(self, fd, name='jarvis'):
self.lib = _lib()
self.ei = self.lib.ei_new_sender(None)
if not self.ei:
raise RuntimeError('ei_new_sender failed')
self.lib.ei_configure_name(self.ei, name.encode())
if self.lib.ei_setup_backend_fd(self.ei, int(fd)) != 0:
raise RuntimeError('ei_setup_backend_fd failed')
self.fd = self.lib.ei_get_fd(self.ei)
self.pointer_abs = None
self.pointer_rel = None
self.keyboard = None
self._emulating = set()
self._seq = 1
@property
def pointer(self):
return self.pointer_abs or self.pointer_rel
def _has(self, device, cap):
return bool(device) and bool(self.lib.ei_device_has_capability(device, cap))
def _adopt(self, current, device):
if _ptr(current) == _ptr(device):
return current
if current:
self.lib.ei_device_unref(current)
return self.lib.ei_device_ref(device)
def _drop(self, device):
key = _ptr(device)
self._emulating.discard(key)
dropped = []
for attr in ('pointer_abs', 'pointer_rel', 'keyboard'):
current = getattr(self, attr)
if _ptr(current) == key:
dropped.append(current)
setattr(self, attr, None)
for current in dropped:
self.lib.ei_device_unref(current)
def _remember(self, device):
if self._has(device, CAP_POINTER_ABSOLUTE):
self.pointer_abs = self._adopt(self.pointer_abs, device)
elif self._has(device, CAP_POINTER):
self.pointer_rel = self._adopt(self.pointer_rel, device)
if self._has(device, CAP_KEYBOARD):
self.keyboard = self._adopt(self.keyboard, device)
def wait_ready(self, timeout=8.0):
deadline = time.time() + timeout
collected = None
while time.time() < deadline:
remaining = max(0.05, deadline - time.time())
readable, _, _ = select.select([self.fd], [], [], remaining)
if readable:
self.dispatch()
if self.pointer_abs and self.keyboard and all(_ptr(d) in self._emulating for d in (self.pointer_abs, self.keyboard)):
if collected is None:
collected = time.time()
elif time.time() - collected >= 0.35 or self.pointer_abs:
return True
return bool(self.pointer_abs and self.keyboard and all(_ptr(d) in self._emulating for d in (self.pointer_abs, self.keyboard)))
def dispatch(self):
self.lib.ei_dispatch(self.ei)
while True:
event = self.lib.ei_get_event(self.ei)
if not event:
break
kind = self.lib.ei_event_get_type(event)
if kind == EI_EVENT_SEAT_ADDED:
seat = self.lib.ei_event_get_seat(event)
caps = [cap for cap in BIND_CAPS if self.lib.ei_seat_has_capability(seat, cap)] or list(BIND_CAPS)
self.lib.ei_seat_bind_capabilities(seat, *[ctypes.c_int(cap) for cap in caps], None)
elif kind == EI_EVENT_DEVICE_ADDED:
self._remember(self.lib.ei_event_get_device(event))
elif kind == EI_EVENT_DEVICE_RESUMED:
device = self.lib.ei_event_get_device(event)
self.lib.ei_device_start_emulating(device, self._seq)
self._seq += 1
self._emulating.add(_ptr(device))
self._remember(device)
elif kind == EI_EVENT_DEVICE_PAUSED:
self._emulating.discard(_ptr(self.lib.ei_event_get_device(event)))
elif kind in (EI_EVENT_DEVICE_REMOVED, EI_EVENT_DISCONNECT):
if kind == EI_EVENT_DEVICE_REMOVED:
self._drop(self.lib.ei_event_get_device(event))
else:
for current in (self.pointer_abs, self.pointer_rel, self.keyboard):
if current:
self._drop(current)
self.lib.ei_event_unref(event)
def _frame(self, device):
self.lib.ei_device_frame(device, self.lib.ei_now(self.ei))
def _button_code(self, name):
if name in ('right', 3, '3'):
return BTN_RIGHT
if name in ('middle', 2, '2'):
return BTN_MIDDLE
return BTN_LEFT
def move(self, x, y):
device = self.pointer_abs
if not device or _ptr(device) not in self._emulating:
raise RuntimeError('no active absolute EIS pointer device')
if self._has(device, CAP_POINTER_ABSOLUTE):
self.lib.ei_device_pointer_motion_absolute(device, float(x), float(y))
else:
self.lib.ei_device_pointer_motion(device, float(x), float(y))
self._frame(device)
def click(self, x, y, button='left', count=1):
self.move(x, y)
device = self.pointer
code = self._button_code(button)
for _ in range(max(1, int(count))):
self.lib.ei_device_button_button(device, code, True)
self._frame(device)
self.lib.ei_device_button_button(device, code, False)
self._frame(device)
def scroll(self, x, y, dx=0, dy=0):
self.move(x, y)
self.lib.ei_device_scroll_discrete(self.pointer, int(dx) * 120, int(dy) * 120)
self._frame(self.pointer)
def drag(self, start, end):
self.move(start[0], start[1])
self.lib.ei_device_button_button(self.pointer, BTN_LEFT, True)
self._frame(self.pointer)
try:
for step in range(1, 13):
self.move(start[0] + (end[0] - start[0]) * step / 12, start[1] + (end[1] - start[1]) * step / 12)
time.sleep(0.012)
finally:
self.lib.ei_device_button_button(self.pointer, BTN_LEFT, False)
self._frame(self.pointer)
def _tap(self, code, mods=()):
if not self.keyboard or _ptr(self.keyboard) not in self._emulating:
raise RuntimeError('no EIS keyboard device')
for mod in mods:
self.lib.ei_device_keyboard_key(self.keyboard, mod, True)
self._frame(self.keyboard)
self.lib.ei_device_keyboard_key(self.keyboard, code, True)
self._frame(self.keyboard)
self.lib.ei_device_keyboard_key(self.keyboard, code, False)
self._frame(self.keyboard)
for mod in reversed(mods):
self.lib.ei_device_keyboard_key(self.keyboard, mod, False)
self._frame(self.keyboard)
def type_text(self, text, submit=False):
for ch in str(text):
if ch == '\n':
self._tap(KEY_ENTER)
continue
if ch == ' ':
self._tap(KEY_SPACE)
continue
if ch == '\t':
self._tap(KEY_TAB)
continue
lower = ch.lower()
code = LETTER_KEYS.get(lower) or DIGIT_KEYS.get(ch)
if code is not None:
self._tap(code, (KEY_LEFTSHIFT,) if ch.isupper() else ())
continue
punct = PUNCT_KEYS.get(ch)
if punct:
code, shifted = punct
self._tap(code, (KEY_LEFTSHIFT,) if shifted else ())
continue
# GNOME/GTK Unicode input method; never silently drop characters.
self._tap(LETTER_KEYS['u'], (KEY_LEFTCTRL, KEY_LEFTSHIFT))
time.sleep(0.08)
for digit in format(ord(ch), 'x'):
self._tap(LETTER_KEYS.get(digit) or DIGIT_KEYS[digit])
time.sleep(0.01)
self._tap(KEY_ENTER)
time.sleep(0.1)
if submit:
self._tap(KEY_ENTER)
def key_combo(self, combo):
parts = [p.strip().lower() for p in str(combo).replace('-', '+').split('+') if p.strip()]
if not parts:
raise ValueError('keyboard combination is required')
modifiers = {'ctrl', 'control', 'alt', 'shift', 'super', 'meta'}
if any(part not in modifiers for part in parts[:-1]):
raise ValueError('unsupported keyboard modifier')
mods = list(dict.fromkeys(COMBO_KEYS[part] for part in parts[:-1]))
key = parts[-1]
code = COMBO_KEYS.get(key) or LETTER_KEYS.get(key) or DIGIT_KEYS.get(key)
if code is None:
raise ValueError(f'unsupported key: {key}')
self._tap(code, tuple(mods))
def handle(self, action):
self.dispatch()
kind = action.get('type')
name = action.get('action')
if kind == 'pointer' and name in ('click', 'double_click'):
self.click(action.get('x') or 0, action.get('y') or 0, action.get('button') or 'left', 2 if name == 'double_click' else 1)
elif kind == 'pointer' and name == 'move':
self.move(action.get('x') or 0, action.get('y') or 0)
elif kind == 'pointer' and name == 'scroll':
self.scroll(action.get('x') or 0, action.get('y') or 0, action.get('dx') or 0, action.get('dy') or 0)
elif kind == 'pointer' and name == 'drag':
self.drag(action.get('from') or [0, 0], action.get('to') or [0, 0])
elif kind == 'keyboard' and name == 'type':
self.type_text(action.get('text') or '', action.get('submit'))
elif kind == 'keyboard' and name == 'key':
self.key_combo(action.get('combo') or '')
else:
raise RuntimeError(f'unsupported EIS action {kind} {name}')
def close(self):
seen = []
for device in (self.pointer_abs, self.pointer_rel, self.keyboard):
if device and _ptr(device) not in seen:
seen.append(_ptr(device))
try:
self.lib.ei_device_unref(device)
except Exception:
pass
self.pointer_abs = self.pointer_rel = self.keyboard = None
self._emulating.clear()
if self.ei:
self.lib.ei_unref(self.ei)
self.ei = None