@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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/extended/diagnostics-audio.mjs +237 -0
- package/src/extended/diagnostics-platform.mjs +308 -0
- package/src/extended/diagnostics.mjs +4 -489
- package/src/extended/documents-filesystem.mjs +270 -0
- package/src/extended/documents-reading.mjs +163 -0
- package/src/extended/documents.mjs +3 -386
- package/src/helpers/wayland-eis-helper.py +2 -339
- package/src/helpers/wayland_eis_sender.py +344 -0
- package/src/wayland-remote-desktop-input.mjs +168 -0
- package/src/wayland-remote-desktop-utils.mjs +27 -0
- package/src/wayland-remote-desktop.mjs +11 -180
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Libei sender and input protocol for the ReMCP Wayland Portal helper.
|
|
2
|
+
|
|
3
|
+
Keep the sender in the same OS process as the portal: the EIS file descriptor
|
|
4
|
+
cannot be transported to Node without changing SCM_RIGHTS and grant semantics.
|
|
5
|
+
"""
|
|
6
|
+
import ctypes
|
|
7
|
+
import select
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
EI_CAP_POINTER = 1 << 0
|
|
11
|
+
EI_CAP_POINTER_ABSOLUTE = 1 << 1
|
|
12
|
+
EI_CAP_KEYBOARD = 1 << 2
|
|
13
|
+
EI_CAP_SCROLL = 1 << 4
|
|
14
|
+
EI_CAP_BUTTON = 1 << 5
|
|
15
|
+
EI_EVENT_CONNECT = 1
|
|
16
|
+
EI_EVENT_DISCONNECT = 2
|
|
17
|
+
EI_EVENT_SEAT_ADDED = 3
|
|
18
|
+
EI_EVENT_SEAT_REMOVED = 4
|
|
19
|
+
EI_EVENT_DEVICE_ADDED = 5
|
|
20
|
+
EI_EVENT_DEVICE_REMOVED = 6
|
|
21
|
+
EI_EVENT_DEVICE_PAUSED = 7
|
|
22
|
+
EI_EVENT_DEVICE_RESUMED = 8
|
|
23
|
+
|
|
24
|
+
class EiSender:
|
|
25
|
+
def __init__(self, fd):
|
|
26
|
+
self.lib = ctypes.CDLL("libei.so.1")
|
|
27
|
+
c_void_p = ctypes.c_void_p
|
|
28
|
+
self.lib.ei_new_sender.argtypes = [c_void_p]
|
|
29
|
+
self.lib.ei_new_sender.restype = c_void_p
|
|
30
|
+
self.lib.ei_configure_name.argtypes = [c_void_p, ctypes.c_char_p]
|
|
31
|
+
self.lib.ei_setup_backend_fd.argtypes = [c_void_p, ctypes.c_int]
|
|
32
|
+
self.lib.ei_setup_backend_fd.restype = ctypes.c_int
|
|
33
|
+
self.lib.ei_get_fd.argtypes = [c_void_p]
|
|
34
|
+
self.lib.ei_get_fd.restype = ctypes.c_int
|
|
35
|
+
self.lib.ei_dispatch.argtypes = [c_void_p]
|
|
36
|
+
self.lib.ei_get_event.argtypes = [c_void_p]
|
|
37
|
+
self.lib.ei_get_event.restype = c_void_p
|
|
38
|
+
self.lib.ei_event_get_type.argtypes = [c_void_p]
|
|
39
|
+
self.lib.ei_event_get_type.restype = ctypes.c_int
|
|
40
|
+
self.lib.ei_event_get_seat.argtypes = [c_void_p]
|
|
41
|
+
self.lib.ei_event_get_seat.restype = c_void_p
|
|
42
|
+
self.lib.ei_event_get_device.argtypes = [c_void_p]
|
|
43
|
+
self.lib.ei_event_get_device.restype = c_void_p
|
|
44
|
+
self.lib.ei_event_unref.argtypes = [c_void_p]
|
|
45
|
+
self.lib.ei_event_unref.restype = c_void_p
|
|
46
|
+
self.lib.ei_device_ref.argtypes = [c_void_p]
|
|
47
|
+
self.lib.ei_device_ref.restype = c_void_p
|
|
48
|
+
self.lib.ei_device_unref.argtypes = [c_void_p]
|
|
49
|
+
self.lib.ei_device_unref.restype = c_void_p
|
|
50
|
+
self.lib.ei_device_has_capability.argtypes = [c_void_p, ctypes.c_int]
|
|
51
|
+
self.lib.ei_device_has_capability.restype = ctypes.c_bool
|
|
52
|
+
self.lib.ei_device_get_region_at.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
|
|
53
|
+
self.lib.ei_device_get_region_at.restype = c_void_p
|
|
54
|
+
self.lib.ei_region_convert_point.argtypes = [c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
|
|
55
|
+
self.lib.ei_region_convert_point.restype = ctypes.c_bool
|
|
56
|
+
self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
|
|
57
|
+
self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
|
|
58
|
+
self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
|
|
59
|
+
self.lib.ei_device_pointer_motion.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
|
|
60
|
+
self.lib.ei_device_pointer_motion_absolute.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
|
|
61
|
+
self.lib.ei_device_button_button.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
|
|
62
|
+
self.lib.ei_device_scroll_discrete.argtypes = [c_void_p, ctypes.c_int32, ctypes.c_int32]
|
|
63
|
+
self.lib.ei_device_frame.argtypes = [c_void_p, ctypes.c_uint64]
|
|
64
|
+
self.lib.ei_now.argtypes = [c_void_p]
|
|
65
|
+
self.lib.ei_now.restype = ctypes.c_uint64
|
|
66
|
+
self.lib.ei_unref.argtypes = [c_void_p]
|
|
67
|
+
self.lib.ei_unref.restype = c_void_p
|
|
68
|
+
self.lib.ei_seat_has_capability.argtypes = [c_void_p, ctypes.c_int]
|
|
69
|
+
self.lib.ei_seat_has_capability.restype = ctypes.c_bool
|
|
70
|
+
# ei_seat_bind_capabilities is varargs: keep argtypes unset.
|
|
71
|
+
self.lib.ei_seat_bind_capabilities.restype = None
|
|
72
|
+
|
|
73
|
+
self.ei = self.lib.ei_new_sender(None)
|
|
74
|
+
if not self.ei:
|
|
75
|
+
raise RuntimeError("ei_new_sender failed")
|
|
76
|
+
self.lib.ei_configure_name(self.ei, b"ReMCP")
|
|
77
|
+
rc = int(self.lib.ei_setup_backend_fd(self.ei, fd))
|
|
78
|
+
if rc < 0:
|
|
79
|
+
raise RuntimeError(f"ei_setup_backend_fd failed ({rc})")
|
|
80
|
+
self.fd = int(self.lib.ei_get_fd(self.ei))
|
|
81
|
+
if self.fd < 0:
|
|
82
|
+
raise RuntimeError("ei_get_fd failed")
|
|
83
|
+
self.devices = {}
|
|
84
|
+
self.sequence = 1
|
|
85
|
+
self.connected = False
|
|
86
|
+
self.pointer_device_key = None
|
|
87
|
+
self.pointer_position = None
|
|
88
|
+
# A press/release pair must stay on the same emulated device even if
|
|
89
|
+
# absolute motion crosses an EIS region and changes pointer_device_key.
|
|
90
|
+
self.button_device_keys = {}
|
|
91
|
+
|
|
92
|
+
def _device_key(self, ptr):
|
|
93
|
+
return int(ptr or 0)
|
|
94
|
+
|
|
95
|
+
def _remember_device(self, ptr):
|
|
96
|
+
key = self._device_key(ptr)
|
|
97
|
+
if not key:
|
|
98
|
+
return None
|
|
99
|
+
if key not in self.devices:
|
|
100
|
+
retained = self.lib.ei_device_ref(ptr)
|
|
101
|
+
self.devices[key] = {"ptr": retained, "resumed": False, "emulating": False}
|
|
102
|
+
return self.devices[key]
|
|
103
|
+
|
|
104
|
+
def _drop_device(self, ptr):
|
|
105
|
+
key = self._device_key(ptr)
|
|
106
|
+
state = self.devices.pop(key, None)
|
|
107
|
+
if key == self.pointer_device_key:
|
|
108
|
+
self.pointer_device_key = None
|
|
109
|
+
self.pointer_position = None
|
|
110
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
111
|
+
if device_key == key:
|
|
112
|
+
self.button_device_keys.pop(button, None)
|
|
113
|
+
if state:
|
|
114
|
+
if state["emulating"]:
|
|
115
|
+
try:
|
|
116
|
+
self.lib.ei_device_stop_emulating(state["ptr"])
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
self.lib.ei_device_unref(state["ptr"])
|
|
120
|
+
|
|
121
|
+
def _bind_seat(self, seat):
|
|
122
|
+
caps = [EI_CAP_POINTER, EI_CAP_POINTER_ABSOLUTE, EI_CAP_KEYBOARD, EI_CAP_SCROLL, EI_CAP_BUTTON]
|
|
123
|
+
args = [ctypes.c_void_p(seat)]
|
|
124
|
+
args.extend(ctypes.c_int(cap) for cap in caps if self.lib.ei_seat_has_capability(seat, cap))
|
|
125
|
+
args.append(ctypes.c_void_p())
|
|
126
|
+
self.lib.ei_seat_bind_capabilities(*args)
|
|
127
|
+
|
|
128
|
+
def process_events(self):
|
|
129
|
+
self.lib.ei_dispatch(self.ei)
|
|
130
|
+
while True:
|
|
131
|
+
event = self.lib.ei_get_event(self.ei)
|
|
132
|
+
if not event:
|
|
133
|
+
break
|
|
134
|
+
try:
|
|
135
|
+
event_type = int(self.lib.ei_event_get_type(event))
|
|
136
|
+
if event_type == EI_EVENT_CONNECT:
|
|
137
|
+
self.connected = True
|
|
138
|
+
elif event_type == EI_EVENT_DISCONNECT:
|
|
139
|
+
raise RuntimeError("EIS server disconnected")
|
|
140
|
+
elif event_type == EI_EVENT_SEAT_ADDED:
|
|
141
|
+
seat = self.lib.ei_event_get_seat(event)
|
|
142
|
+
if seat:
|
|
143
|
+
self._bind_seat(seat)
|
|
144
|
+
elif event_type == EI_EVENT_DEVICE_ADDED:
|
|
145
|
+
device = self.lib.ei_event_get_device(event)
|
|
146
|
+
if device:
|
|
147
|
+
self._remember_device(device)
|
|
148
|
+
elif event_type == EI_EVENT_DEVICE_RESUMED:
|
|
149
|
+
device = self.lib.ei_event_get_device(event)
|
|
150
|
+
state = self._remember_device(device)
|
|
151
|
+
if state:
|
|
152
|
+
state["resumed"] = True
|
|
153
|
+
if not state["emulating"]:
|
|
154
|
+
self.lib.ei_device_start_emulating(state["ptr"], self.sequence)
|
|
155
|
+
self.sequence = (self.sequence + 1) & 0xFFFFFFFF or 1
|
|
156
|
+
state["emulating"] = True
|
|
157
|
+
elif event_type == EI_EVENT_DEVICE_PAUSED:
|
|
158
|
+
device = self.lib.ei_event_get_device(event)
|
|
159
|
+
key = self._device_key(device)
|
|
160
|
+
state = self.devices.get(key)
|
|
161
|
+
if state:
|
|
162
|
+
state["resumed"] = False
|
|
163
|
+
state["emulating"] = False
|
|
164
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
165
|
+
if device_key == key:
|
|
166
|
+
self.button_device_keys.pop(button, None)
|
|
167
|
+
elif event_type == EI_EVENT_DEVICE_REMOVED:
|
|
168
|
+
self._drop_device(self.lib.ei_event_get_device(event))
|
|
169
|
+
finally:
|
|
170
|
+
self.lib.ei_event_unref(event)
|
|
171
|
+
|
|
172
|
+
def wait_ready(self, timeout_s=10.0):
|
|
173
|
+
deadline = time.monotonic() + timeout_s
|
|
174
|
+
while time.monotonic() < deadline:
|
|
175
|
+
if (
|
|
176
|
+
self.connected
|
|
177
|
+
and self.capability_ready(EI_CAP_KEYBOARD)
|
|
178
|
+
and self.capability_ready(EI_CAP_POINTER)
|
|
179
|
+
and self.capability_ready(EI_CAP_BUTTON)
|
|
180
|
+
and self.capability_ready(EI_CAP_SCROLL)
|
|
181
|
+
):
|
|
182
|
+
return
|
|
183
|
+
readable, _, _ = select.select([self.fd], [], [], min(0.1, max(0.0, deadline - time.monotonic())))
|
|
184
|
+
if readable:
|
|
185
|
+
self.process_events()
|
|
186
|
+
raise TimeoutError(f"EIS devices did not become ready; capabilities={self.capabilities()}")
|
|
187
|
+
|
|
188
|
+
def capability_ready(self, cap):
|
|
189
|
+
return any(state["resumed"] and self.lib.ei_device_has_capability(state["ptr"], cap) for state in self.devices.values())
|
|
190
|
+
|
|
191
|
+
def capabilities(self):
|
|
192
|
+
names = {
|
|
193
|
+
EI_CAP_POINTER: "pointer",
|
|
194
|
+
EI_CAP_POINTER_ABSOLUTE: "pointer_absolute",
|
|
195
|
+
EI_CAP_KEYBOARD: "keyboard",
|
|
196
|
+
EI_CAP_SCROLL: "scroll",
|
|
197
|
+
EI_CAP_BUTTON: "button",
|
|
198
|
+
}
|
|
199
|
+
return {name: self.capability_ready(cap) for cap, name in names.items()}
|
|
200
|
+
|
|
201
|
+
def device_for(self, cap):
|
|
202
|
+
for state in self.devices.values():
|
|
203
|
+
if state["resumed"] and state["emulating"] and self.lib.ei_device_has_capability(state["ptr"], cap):
|
|
204
|
+
return state["ptr"]
|
|
205
|
+
raise RuntimeError(f"no resumed EIS device with capability {cap}")
|
|
206
|
+
|
|
207
|
+
def _absolute_motion_for_device(self, device, x, y):
|
|
208
|
+
if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
209
|
+
return None
|
|
210
|
+
region = self.lib.ei_device_get_region_at(device, float(x), float(y))
|
|
211
|
+
if not region:
|
|
212
|
+
return None
|
|
213
|
+
motion_x = ctypes.c_double(float(x))
|
|
214
|
+
motion_y = ctypes.c_double(float(y))
|
|
215
|
+
if not self.lib.ei_region_convert_point(region, ctypes.byref(motion_x), ctypes.byref(motion_y)):
|
|
216
|
+
raise RuntimeError(f"failed to convert desktop coordinate ({x}, {y}) into the selected EIS region")
|
|
217
|
+
return motion_x.value, motion_y.value
|
|
218
|
+
|
|
219
|
+
def device_for_absolute(self, x, y):
|
|
220
|
+
candidates = []
|
|
221
|
+
|
|
222
|
+
# Preserve pointer identity across absolute motion whenever the current
|
|
223
|
+
# device still covers the destination. This is required for pointer
|
|
224
|
+
# capture during drags: switching EIS devices mid-gesture can drop the
|
|
225
|
+
# captured pointer before its button release is observed.
|
|
226
|
+
preferred = self.devices.get(self.pointer_device_key)
|
|
227
|
+
if preferred and preferred["resumed"] and preferred["emulating"]:
|
|
228
|
+
device = preferred["ptr"]
|
|
229
|
+
if self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
230
|
+
candidates.append(device)
|
|
231
|
+
converted = self._absolute_motion_for_device(device, x, y)
|
|
232
|
+
if converted is not None:
|
|
233
|
+
return device, converted[0], converted[1]
|
|
234
|
+
|
|
235
|
+
for state in self.devices.values():
|
|
236
|
+
if not state["resumed"] or not state["emulating"]:
|
|
237
|
+
continue
|
|
238
|
+
device = state["ptr"]
|
|
239
|
+
if preferred and self._device_key(device) == self.pointer_device_key:
|
|
240
|
+
continue
|
|
241
|
+
if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
242
|
+
continue
|
|
243
|
+
candidates.append(device)
|
|
244
|
+
converted = self._absolute_motion_for_device(device, x, y)
|
|
245
|
+
if converted is not None:
|
|
246
|
+
return device, converted[0], converted[1]
|
|
247
|
+
if candidates:
|
|
248
|
+
raise RuntimeError(f"no absolute-pointer EIS region contains desktop coordinate ({x}, {y})")
|
|
249
|
+
raise RuntimeError("no resumed EIS device with absolute-pointer capability")
|
|
250
|
+
|
|
251
|
+
def device_for_pointer_context(self, cap):
|
|
252
|
+
state = self.devices.get(self.pointer_device_key)
|
|
253
|
+
if (
|
|
254
|
+
state
|
|
255
|
+
and state["resumed"]
|
|
256
|
+
and state["emulating"]
|
|
257
|
+
and self.lib.ei_device_has_capability(state["ptr"], cap)
|
|
258
|
+
):
|
|
259
|
+
return state["ptr"]
|
|
260
|
+
if self.pointer_position is not None:
|
|
261
|
+
x, y = self.pointer_position
|
|
262
|
+
for candidate in self.devices.values():
|
|
263
|
+
if not candidate["resumed"] or not candidate["emulating"]:
|
|
264
|
+
continue
|
|
265
|
+
device = candidate["ptr"]
|
|
266
|
+
if not self.lib.ei_device_has_capability(device, cap):
|
|
267
|
+
continue
|
|
268
|
+
if self.lib.ei_device_get_region_at(device, float(x), float(y)):
|
|
269
|
+
return device
|
|
270
|
+
return self.device_for(cap)
|
|
271
|
+
|
|
272
|
+
def remember_pointer(self, device, position=None):
|
|
273
|
+
self.pointer_device_key = self._device_key(device)
|
|
274
|
+
self.pointer_position = position
|
|
275
|
+
|
|
276
|
+
def frame(self, device):
|
|
277
|
+
self.lib.ei_device_frame(device, self.lib.ei_now(self.ei))
|
|
278
|
+
|
|
279
|
+
def command(self, payload):
|
|
280
|
+
op = str(payload.get("op") or "")
|
|
281
|
+
if op == "key":
|
|
282
|
+
device = self.device_for(EI_CAP_KEYBOARD)
|
|
283
|
+
self.lib.ei_device_keyboard_key(device, int(payload["keycode"]), bool(payload.get("pressed")))
|
|
284
|
+
self.frame(device)
|
|
285
|
+
elif op == "motion":
|
|
286
|
+
device = self.device_for(EI_CAP_POINTER)
|
|
287
|
+
self.lib.ei_device_pointer_motion(device, float(payload.get("dx", 0)), float(payload.get("dy", 0)))
|
|
288
|
+
self.remember_pointer(device)
|
|
289
|
+
self.frame(device)
|
|
290
|
+
elif op == "motion_absolute":
|
|
291
|
+
x = float(payload["x"])
|
|
292
|
+
y = float(payload["y"])
|
|
293
|
+
device, motion_x, motion_y = self.device_for_absolute(x, y)
|
|
294
|
+
self.lib.ei_device_pointer_motion_absolute(device, motion_x, motion_y)
|
|
295
|
+
self.remember_pointer(device, (x, y))
|
|
296
|
+
self.frame(device)
|
|
297
|
+
elif op == "button":
|
|
298
|
+
button = int(payload["button"])
|
|
299
|
+
pressed = bool(payload.get("pressed"))
|
|
300
|
+
if pressed:
|
|
301
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
302
|
+
self.button_device_keys[button] = self._device_key(device)
|
|
303
|
+
else:
|
|
304
|
+
key = self.button_device_keys.pop(button, None)
|
|
305
|
+
state = self.devices.get(key) if key else None
|
|
306
|
+
if (
|
|
307
|
+
state
|
|
308
|
+
and state["resumed"]
|
|
309
|
+
and state["emulating"]
|
|
310
|
+
and self.lib.ei_device_has_capability(state["ptr"], EI_CAP_BUTTON)
|
|
311
|
+
):
|
|
312
|
+
device = state["ptr"]
|
|
313
|
+
else:
|
|
314
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
315
|
+
self.lib.ei_device_button_button(device, button, pressed)
|
|
316
|
+
self.frame(device)
|
|
317
|
+
elif op == "scroll":
|
|
318
|
+
device = self.device_for_pointer_context(EI_CAP_SCROLL)
|
|
319
|
+
self.lib.ei_device_scroll_discrete(device, int(payload.get("dx", 0)), int(payload.get("dy", 0)))
|
|
320
|
+
self.frame(device)
|
|
321
|
+
elif op == "ping":
|
|
322
|
+
pass
|
|
323
|
+
elif op == "close":
|
|
324
|
+
return False
|
|
325
|
+
else:
|
|
326
|
+
raise ValueError(f"unsupported EIS operation: {op}")
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
def close(self):
|
|
330
|
+
for state in list(self.devices.values()):
|
|
331
|
+
if state["emulating"]:
|
|
332
|
+
try:
|
|
333
|
+
self.lib.ei_device_stop_emulating(state["ptr"])
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
try:
|
|
337
|
+
self.lib.ei_device_unref(state["ptr"])
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
self.devices.clear()
|
|
341
|
+
self.button_device_keys.clear()
|
|
342
|
+
if self.ei:
|
|
343
|
+
self.lib.ei_unref(self.ei)
|
|
344
|
+
self.ei = None
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ToolError } from './util.mjs';
|
|
2
|
+
|
|
3
|
+
// Bind input operations to the owning portal session manager. Never establish an
|
|
4
|
+
// independent session here: restore tokens, grant revocation and cleanup belong to
|
|
5
|
+
// wayland-remote-desktop.mjs.
|
|
6
|
+
export function createWaylandInput(ensureWaylandRemoteDesktop) {
|
|
7
|
+
function keysymForToken(tokenValue) {
|
|
8
|
+
const token = String(tokenValue || '').trim();
|
|
9
|
+
const normalized = token.toLowerCase();
|
|
10
|
+
const named = {
|
|
11
|
+
ctrl: 0xffe3, control: 0xffe3, shift: 0xffe1, alt: 0xffe9, option: 0xffe9,
|
|
12
|
+
super: 0xffeb, meta: 0xffeb, cmd: 0xffeb, command: 0xffeb,
|
|
13
|
+
enter: 0xff0d, return: 0xff0d, esc: 0xff1b, escape: 0xff1b, tab: 0xff09,
|
|
14
|
+
backspace: 0xff08, delete: 0xffff, up: 0xff52, down: 0xff54, left: 0xff51, right: 0xff53,
|
|
15
|
+
home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56, space: 0x20,
|
|
16
|
+
};
|
|
17
|
+
if (named[normalized]) return named[normalized];
|
|
18
|
+
const fn = normalized.match(/^f(\d{1,2})$/);
|
|
19
|
+
if (fn) {
|
|
20
|
+
const number = Number(fn[1]);
|
|
21
|
+
if (number >= 1 && number <= 35) return 0xffbd + number;
|
|
22
|
+
}
|
|
23
|
+
const chars = [...token];
|
|
24
|
+
if (chars.length !== 1) throw new ToolError(`Unsupported keyboard token: ${token}`);
|
|
25
|
+
const codePoint = chars[0].codePointAt(0);
|
|
26
|
+
return codePoint <= 0xff ? codePoint : (0x01000000 | codePoint);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function notifyKeysym(state, keysym, keyState) {
|
|
30
|
+
if (state.backend === 'xdg-eis') throw new ToolError('EIS 1.2 keyboard input requires evdev keycodes, not X11 keysyms');
|
|
31
|
+
await state.remote.NotifyKeyboardKeysym(state.session, {}, keysym, keyState);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function notifyKeycode(state, keycode, keyState) {
|
|
35
|
+
if (state.backend === 'xdg-eis') {
|
|
36
|
+
await state.send({ op:'key', keycode:Number(keycode), pressed:keyState === 1 });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await state.remote.NotifyKeyboardKeycode(state.session, {}, keycode, keyState);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function keycodeForToken(tokenValue) {
|
|
43
|
+
const token = String(tokenValue || '').trim().toLowerCase();
|
|
44
|
+
const named = {
|
|
45
|
+
esc:1, escape:1,
|
|
46
|
+
'1':2, '2':3, '3':4, '4':5, '5':6, '6':7, '7':8, '8':9, '9':10, '0':11,
|
|
47
|
+
minus:12, '-':12, equal:13, '=':13, backspace:14, tab:15,
|
|
48
|
+
q:16, w:17, e:18, r:19, t:20, y:21, u:22, i:23, o:24, p:25,
|
|
49
|
+
'[':26, ']':27, enter:28, return:28, ctrl:29, control:29,
|
|
50
|
+
a:30, s:31, d:32, f:33, g:34, h:35, j:36, k:37, l:38,
|
|
51
|
+
';':39, "'":40, grave:41, backtick:41, '`':41, shift:42, '\\':43,
|
|
52
|
+
z:44, x:45, c:46, v:47, b:48, n:49, m:50, ',':51, '.':52, '/':53,
|
|
53
|
+
alt:56, option:56, space:57,
|
|
54
|
+
f1:59, f2:60, f3:61, f4:62, f5:63, f6:64, f7:65, f8:66, f9:67, f10:68,
|
|
55
|
+
f11:87, f12:88,
|
|
56
|
+
home:102, up:103, pageup:104, left:105, right:106, end:107, down:108, pagedown:109,
|
|
57
|
+
insert:110, delete:111,
|
|
58
|
+
super:125, meta:125, cmd:125, command:125,
|
|
59
|
+
};
|
|
60
|
+
return Number.isInteger(named[token]) ? named[token] : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function notifyToken(state, token, keyState) {
|
|
64
|
+
if (state.backend === 'xdg-eis') {
|
|
65
|
+
const keycode = keycodeForToken(token);
|
|
66
|
+
if (!Number.isInteger(keycode)) throw new ToolError(`Unsupported EIS keyboard token: ${token}`);
|
|
67
|
+
return notifyKeycode(state, keycode, keyState);
|
|
68
|
+
}
|
|
69
|
+
return notifyKeysym(state, keysymForToken(token), keyState);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function portalShortcut(shortcut, options = {}) {
|
|
73
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
74
|
+
const tokens = String(shortcut || '').split('+').map(value => value.trim()).filter(Boolean);
|
|
75
|
+
if (!tokens.length) throw new ToolError('shortcut or key is required');
|
|
76
|
+
const modifiers = tokens.slice(0, -1);
|
|
77
|
+
const key = tokens.at(-1);
|
|
78
|
+
for (const modifier of modifiers) await notifyToken(state, modifier, 1);
|
|
79
|
+
await notifyToken(state, key, 1);
|
|
80
|
+
await notifyToken(state, key, 0);
|
|
81
|
+
for (const modifier of [...modifiers].reverse()) await notifyToken(state, modifier, 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function eisStrokeForChar(char) {
|
|
85
|
+
if (char === '\n') return { keycode:28, shift:false };
|
|
86
|
+
if (char === '\t') return { keycode:15, shift:false };
|
|
87
|
+
if (char === ' ') return { keycode:57, shift:false };
|
|
88
|
+
if (/^[a-z]$/.test(char)) return { keycode:keycodeForToken(char), shift:false };
|
|
89
|
+
if (/^[A-Z]$/.test(char)) return { keycode:keycodeForToken(char), shift:true };
|
|
90
|
+
if (/^[0-9]$/.test(char)) return { keycode:keycodeForToken(char), shift:false };
|
|
91
|
+
const plain = { '-':12, '=':13, '[':26, ']':27, ';':39, "'":40, '`':41, '\\':43, ',':51, '.':52, '/':53 };
|
|
92
|
+
if (Number.isInteger(plain[char])) return { keycode:plain[char], shift:false };
|
|
93
|
+
const shifted = { '!':2, '@':3, '#':4, '$':5, '%':6, '^':7, '&':8, '*':9, '(':10, ')':11, '_':12, '+':13, '{':26, '}':27, ':':39, '"':40, '~':41, '|':43, '<':51, '>':52, '?':53 };
|
|
94
|
+
if (Number.isInteger(shifted[char])) return { keycode:shifted[char], shift:true };
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function portalTypeText(value, { delayMs = 1, timeoutMs, state: suppliedState } = {}) {
|
|
99
|
+
const state = suppliedState || await ensureWaylandRemoteDesktop({ timeoutMs });
|
|
100
|
+
const input = String(value ?? '');
|
|
101
|
+
if (state.backend === 'xdg-eis') {
|
|
102
|
+
const strokes = [...input].map(eisStrokeForChar);
|
|
103
|
+
if (strokes.some(stroke => !stroke)) {
|
|
104
|
+
throw new ToolError('EIS 1.2 direct typing supports ASCII only; use accessibility or clipboard-paste input for Unicode text');
|
|
105
|
+
}
|
|
106
|
+
for (const stroke of strokes) {
|
|
107
|
+
if (stroke.shift) await notifyKeycode(state, 42, 1);
|
|
108
|
+
await notifyKeycode(state, stroke.keycode, 1);
|
|
109
|
+
await notifyKeycode(state, stroke.keycode, 0);
|
|
110
|
+
if (stroke.shift) await notifyKeycode(state, 42, 0);
|
|
111
|
+
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, Math.min(1000, delayMs)));
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const char of input) {
|
|
116
|
+
const keysym = char === '\n' ? 0xff0d : char === '\t' ? 0xff09 : keysymForToken(char);
|
|
117
|
+
await notifyKeysym(state, keysym, 1);
|
|
118
|
+
await notifyKeysym(state, keysym, 0);
|
|
119
|
+
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, Math.min(1000, delayMs)));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function portalPointerMotion(dx, dy, options = {}) {
|
|
124
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
125
|
+
if (state.backend === 'xdg-eis') {
|
|
126
|
+
await state.send({ op:'motion', dx:Number(dx), dy:Number(dy) });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await state.remote.NotifyPointerMotion(state.session, {}, Number(dx), Number(dy));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function portalPointerMotionAbsolute(x, y, options = {}) {
|
|
133
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
134
|
+
if (state.backend === 'xdg-eis') {
|
|
135
|
+
await state.send({ op:'motion_absolute', x:Number(x), y:Number(y) });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new ToolError('Absolute pointer motion requires the EIS Remote Desktop backend');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function portalPointerButton(button, pressed, options = {}) {
|
|
142
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
143
|
+
const code = String(button || 'left').toLowerCase() === 'right' ? 0x111 : String(button || 'left').toLowerCase() === 'middle' ? 0x112 : 0x110;
|
|
144
|
+
if (state.backend === 'xdg-eis') {
|
|
145
|
+
await state.send({ op:'button', button:code, pressed:Boolean(pressed) });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await state.remote.NotifyPointerButton(state.session, {}, code, pressed ? 1 : 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function portalScroll(dx, dy, options = {}) {
|
|
152
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
153
|
+
const steps = delta => delta ? Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / 120)) : 0;
|
|
154
|
+
const x = steps(dx), y = steps(dy);
|
|
155
|
+
if (state.backend === 'xdg-eis') {
|
|
156
|
+
await state.send({ op:'scroll', dx:x * 120, dy:y * 120 });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (x) await state.remote.NotifyPointerAxisDiscrete(state.session, {}, 1, x);
|
|
160
|
+
if (y) await state.remote.NotifyPointerAxisDiscrete(state.session, {}, 0, y);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
portalShortcut, portalTypeText, portalPointerMotion, portalPointerMotionAbsolute,
|
|
166
|
+
portalPointerButton, portalScroll,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { ToolError } from './util.mjs';
|
|
3
|
+
|
|
4
|
+
export function cleanError(error) {
|
|
5
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim().slice(0, 500) || 'unknown error';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function unwrap(value) {
|
|
9
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return unwrap(value.value);
|
|
10
|
+
if (Array.isArray(value)) return value.map(unwrap);
|
|
11
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrap(item)]));
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function token(prefix) {
|
|
16
|
+
return `${prefix}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`.replace(/[^A-Za-z0-9_]/g, '_');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function withTimeout(promise, timeoutMs, label) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const timer = setTimeout(() => reject(new ToolError(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
22
|
+
Promise.resolve(promise).then(
|
|
23
|
+
value => { clearTimeout(timer); resolve(value); },
|
|
24
|
+
error => { clearTimeout(timer); reject(error); },
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
}
|