@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.
@@ -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
+ }