@remcp/runtime 0.2.47 → 0.2.48
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 +15 -0
- package/README.md +42 -25
- package/package.json +6 -2
- package/src/catalog.mjs +1 -1
- package/src/extended/browser.mjs +592 -0
- package/src/extended/catalog.mjs +564 -0
- package/src/extended/common.mjs +209 -0
- package/src/extended/desktop-linux.mjs +1173 -0
- package/src/extended/desktop-macos.mjs +408 -0
- package/src/extended/desktop-windows.mjs +546 -0
- package/src/extended/desktop.mjs +1313 -0
- package/src/extended/diagnostics.mjs +274 -0
- package/src/extended/documents.mjs +381 -0
- package/src/extended/ocr.mjs +139 -0
- package/src/helpers/wayland-eis-helper.py +450 -0
- package/src/index.mjs +46 -44
- package/src/invoke.mjs +3 -3
- package/src/mcp-v2.mjs +104 -0
- package/src/permissions.mjs +1 -1
- package/src/util.mjs +24 -1
- package/src/wayland-remote-desktop.mjs +654 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { runtimeConfigDir } from './config.mjs';
|
|
9
|
+
import { ToolError } from './util.mjs';
|
|
10
|
+
|
|
11
|
+
const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
12
|
+
const PORTAL_PATH = '/org/freedesktop/portal/desktop';
|
|
13
|
+
const REMOTE_INTERFACE = 'org.freedesktop.portal.RemoteDesktop';
|
|
14
|
+
const REQUEST_INTERFACE = 'org.freedesktop.portal.Request';
|
|
15
|
+
const SESSION_INTERFACE = 'org.freedesktop.portal.Session';
|
|
16
|
+
const DBUS_NAME = 'org.freedesktop.DBus';
|
|
17
|
+
const DBUS_PATH = '/org/freedesktop/DBus';
|
|
18
|
+
const DEVICES = 1 | 2;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
20
|
+
const portalStateDir = path.resolve(process.env.REMCP_RUNTIME_PORTAL_STATE_DIR || runtimeConfigDir);
|
|
21
|
+
const TOKEN_FILE = path.join(portalStateDir, 'remote-desktop-portal.json');
|
|
22
|
+
const EIS_HELPER = fileURLToPath(new URL('./helpers/wayland-eis-helper.py', import.meta.url));
|
|
23
|
+
const EIS_LIB_CANDIDATES = ['/lib/x86_64-linux-gnu/libei.so.1','/usr/lib/x86_64-linux-gnu/libei.so.1','/lib/aarch64-linux-gnu/libei.so.1','/usr/lib/aarch64-linux-gnu/libei.so.1'];
|
|
24
|
+
|
|
25
|
+
let active = null;
|
|
26
|
+
let starting = null;
|
|
27
|
+
let dbusModule = null;
|
|
28
|
+
|
|
29
|
+
function cleanError(error) {
|
|
30
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim().slice(0, 500) || 'unknown error';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function unwrap(value) {
|
|
34
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return unwrap(value.value);
|
|
35
|
+
if (Array.isArray(value)) return value.map(unwrap);
|
|
36
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrap(item)]));
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function token(prefix) {
|
|
41
|
+
return `${prefix}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`.replace(/[^A-Za-z0-9_]/g, '_');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => reject(new ToolError(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
47
|
+
Promise.resolve(promise).then(
|
|
48
|
+
value => { clearTimeout(timer); resolve(value); },
|
|
49
|
+
error => { clearTimeout(timer); reject(error); },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadDbus() {
|
|
55
|
+
dbusModule ||= await import('@jellybrick/dbus-next');
|
|
56
|
+
return dbusModule;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForBusName(bus, timeoutMs) {
|
|
60
|
+
if (typeof bus.name === 'string' && bus.name.startsWith(':')) return bus.name;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let timer;
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
bus.off?.('connect', onConnect);
|
|
66
|
+
bus.off?.('error', onError);
|
|
67
|
+
};
|
|
68
|
+
const onConnect = () => {
|
|
69
|
+
if (typeof bus.name !== 'string' || !bus.name.startsWith(':')) {
|
|
70
|
+
cleanup();
|
|
71
|
+
reject(new ToolError('session D-Bus connected without a unique bus name'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const name = bus.name;
|
|
75
|
+
cleanup();
|
|
76
|
+
resolve(name);
|
|
77
|
+
};
|
|
78
|
+
const onError = error => {
|
|
79
|
+
cleanup();
|
|
80
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
81
|
+
};
|
|
82
|
+
bus.once('connect', onConnect);
|
|
83
|
+
bus.once('error', onError);
|
|
84
|
+
timer = setTimeout(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
reject(new ToolError(`session D-Bus did not connect within ${timeoutMs} ms`));
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requestPath(uniqueBusName, requestToken) {
|
|
92
|
+
const sender = uniqueBusName.replace(/^:/, '').replace(/\./g, '_');
|
|
93
|
+
return `/org/freedesktop/portal/desktop/request/${sender}/${requestToken}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function matchRule() {
|
|
97
|
+
return `type='signal',sender='${PORTAL_NAME}',interface='${REQUEST_INTERFACE}',member='Response'`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function dbusMatchMessage(dbus, member, rule) {
|
|
101
|
+
return new dbus.Message({
|
|
102
|
+
destination: DBUS_NAME,
|
|
103
|
+
path: DBUS_PATH,
|
|
104
|
+
interface: DBUS_NAME,
|
|
105
|
+
member,
|
|
106
|
+
signature: 's',
|
|
107
|
+
body: [rule],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function responseCollector(bus, dbus, initialPath, timeoutMs) {
|
|
112
|
+
let targetPath = initialPath;
|
|
113
|
+
let settled = false;
|
|
114
|
+
const buffered = new Map();
|
|
115
|
+
let resolveResponse;
|
|
116
|
+
const promise = new Promise(resolve => { resolveResponse = resolve; });
|
|
117
|
+
const finish = value => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
resolveResponse(value);
|
|
122
|
+
};
|
|
123
|
+
const onMessage = message => {
|
|
124
|
+
if (message?.type !== dbus.MessageType.SIGNAL) return;
|
|
125
|
+
if (message.interface !== REQUEST_INTERFACE || message.member !== 'Response') return;
|
|
126
|
+
if (typeof message.path !== 'string') return;
|
|
127
|
+
const value = {
|
|
128
|
+
response: Number(message.body?.[0]),
|
|
129
|
+
results: unwrap(message.body?.[1] || {}),
|
|
130
|
+
};
|
|
131
|
+
if (message.path === targetPath) finish(value);
|
|
132
|
+
else buffered.set(message.path, value);
|
|
133
|
+
};
|
|
134
|
+
const timer = setTimeout(() => finish({ timeout: true, results: {} }), timeoutMs);
|
|
135
|
+
bus.on('message', onMessage);
|
|
136
|
+
return {
|
|
137
|
+
promise,
|
|
138
|
+
setPath(value) {
|
|
139
|
+
targetPath = value;
|
|
140
|
+
const bufferedValue = buffered.get(value);
|
|
141
|
+
if (bufferedValue) finish(bufferedValue);
|
|
142
|
+
},
|
|
143
|
+
stop() {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
bus.off?.('message', onMessage);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function portalRequest(context, label, call, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
151
|
+
const { bus, dbus, uniqueBusName } = context;
|
|
152
|
+
const requestToken = token(label);
|
|
153
|
+
const rule = matchRule();
|
|
154
|
+
const collector = responseCollector(bus, dbus, requestPath(uniqueBusName, requestToken), timeoutMs);
|
|
155
|
+
let installed = false;
|
|
156
|
+
try {
|
|
157
|
+
await withTimeout(bus.call(dbusMatchMessage(dbus, 'AddMatch', rule)), Math.min(timeoutMs, 5000), `${label} signal subscription`);
|
|
158
|
+
installed = true;
|
|
159
|
+
const handle = await withTimeout(call(requestToken), Math.min(timeoutMs, 10_000), `${label} request`);
|
|
160
|
+
if (typeof handle !== 'string' || !handle.startsWith('/')) throw new ToolError(`${label} returned an invalid request handle`);
|
|
161
|
+
collector.setPath(handle);
|
|
162
|
+
const response = await collector.promise;
|
|
163
|
+
if (response.timeout) throw new ToolError(`${label} timed out after ${timeoutMs} ms`);
|
|
164
|
+
if (response.response === 1) throw new ToolError(`${label} was cancelled on the computer`);
|
|
165
|
+
if (response.response !== 0) throw new ToolError(`${label} was denied by the desktop portal (response ${response.response})`);
|
|
166
|
+
return response.results;
|
|
167
|
+
} finally {
|
|
168
|
+
collector.stop();
|
|
169
|
+
if (installed) {
|
|
170
|
+
try { await bus.call(dbusMatchMessage(dbus, 'RemoveMatch', rule)); } catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readRestoreToken() {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(await readFile(TOKEN_FILE, 'utf8'));
|
|
178
|
+
return typeof parsed.restore_token === 'string' && parsed.restore_token ? parsed.restore_token : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function persistRestoreToken(restoreToken) {
|
|
185
|
+
if (!restoreToken) return;
|
|
186
|
+
await mkdir(portalStateDir, { recursive: true, mode: 0o700 });
|
|
187
|
+
await writeFile(TOKEN_FILE, `${JSON.stringify({ version: 1, restore_token: restoreToken }, null, 2)}\n`, { mode: 0o600 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function clearRestoreToken() {
|
|
191
|
+
await rm(TOKEN_FILE, { force: true }).catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function eisUnsupported(error) {
|
|
195
|
+
return Boolean(error && typeof error === 'object' && error.eisUnsupported === true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function eisHelperCandidate(env = process.env) {
|
|
199
|
+
if (process.platform !== 'linux' || !existsSync(EIS_HELPER)) return false;
|
|
200
|
+
if (!/wayland/i.test(String(env.XDG_SESSION_TYPE || '')) && !env.WAYLAND_DISPLAY) return false;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function helperError(message, stderr = '') {
|
|
205
|
+
const detail = cleanError(message?.error || stderr || 'EIS helper failed');
|
|
206
|
+
const error = new ToolError(`Wayland EIS input failed: ${detail}`);
|
|
207
|
+
if (message?.kind === 'unsupported') error.eisUnsupported = true;
|
|
208
|
+
return error;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function createWaylandEisSession({
|
|
212
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
213
|
+
parentWindow = '',
|
|
214
|
+
env = process.env,
|
|
215
|
+
readToken = readRestoreToken,
|
|
216
|
+
persistToken = persistRestoreToken,
|
|
217
|
+
} = {}) {
|
|
218
|
+
if (!eisHelperCandidate(env)) {
|
|
219
|
+
const error = new ToolError('Wayland EIS helper prerequisites are unavailable');
|
|
220
|
+
error.eisUnsupported = true;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const restoreToken = await readToken();
|
|
225
|
+
const python = existsSync('/usr/bin/python3') ? '/usr/bin/python3' : 'python3';
|
|
226
|
+
const child = spawn(python, [EIS_HELPER], {
|
|
227
|
+
env: {
|
|
228
|
+
...env,
|
|
229
|
+
REMCP_EIS_TIMEOUT_MS:String(Math.max(1000, Math.min(120_000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS))),
|
|
230
|
+
REMCP_EIS_PARENT_WINDOW:String(parentWindow || ''),
|
|
231
|
+
REMCP_EIS_RESTORE_TOKEN:restoreToken || '',
|
|
232
|
+
},
|
|
233
|
+
stdio:['pipe','pipe','pipe'],
|
|
234
|
+
});
|
|
235
|
+
child.stdout?.setEncoding('utf8');
|
|
236
|
+
child.stderr?.setEncoding('utf8');
|
|
237
|
+
|
|
238
|
+
let stderr = '';
|
|
239
|
+
let buffer = '';
|
|
240
|
+
let readySettled = false;
|
|
241
|
+
let requestId = 0;
|
|
242
|
+
const pending = new Map();
|
|
243
|
+
let state = null;
|
|
244
|
+
|
|
245
|
+
const failPending = error => {
|
|
246
|
+
for (const entry of pending.values()) {
|
|
247
|
+
clearTimeout(entry.timer);
|
|
248
|
+
entry.reject(error);
|
|
249
|
+
}
|
|
250
|
+
pending.clear();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const ready = new Promise((resolve, reject) => {
|
|
254
|
+
const timer = setTimeout(() => {
|
|
255
|
+
if (readySettled) return;
|
|
256
|
+
readySettled = true;
|
|
257
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
258
|
+
reject(new ToolError(`Wayland EIS helper timed out after ${timeoutMs} ms`));
|
|
259
|
+
}, timeoutMs);
|
|
260
|
+
|
|
261
|
+
const settleReady = async message => {
|
|
262
|
+
if (readySettled) return;
|
|
263
|
+
readySettled = true;
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
if (!message?.ready) {
|
|
266
|
+
reject(helperError(message, stderr));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
if (typeof message.restore_token === 'string' && message.restore_token) await persistToken(message.restore_token);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
reject(error);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
state = {
|
|
276
|
+
backend:'xdg-eis',
|
|
277
|
+
child,
|
|
278
|
+
devices:Number(message.devices || 0),
|
|
279
|
+
capabilities:message.capabilities && typeof message.capabilities === 'object' ? { ...message.capabilities } : {},
|
|
280
|
+
send(payload, commandTimeoutMs = 5000) {
|
|
281
|
+
if (!child.stdin || child.exitCode != null || child.killed) {
|
|
282
|
+
return Promise.reject(new ToolError('Wayland EIS helper is not running'));
|
|
283
|
+
}
|
|
284
|
+
const id = ++requestId;
|
|
285
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
pending.delete(id);
|
|
288
|
+
rejectCommand(new ToolError(`Wayland EIS ${payload.op || 'command'} timed out after ${commandTimeoutMs} ms`));
|
|
289
|
+
}, commandTimeoutMs);
|
|
290
|
+
pending.set(id, { resolve:resolveCommand, reject:rejectCommand, timer });
|
|
291
|
+
try {
|
|
292
|
+
child.stdin.write(`${JSON.stringify({ ...payload, id })}\n`, error => {
|
|
293
|
+
if (!error) return;
|
|
294
|
+
const entry = pending.get(id);
|
|
295
|
+
if (!entry) return;
|
|
296
|
+
pending.delete(id);
|
|
297
|
+
clearTimeout(entry.timer);
|
|
298
|
+
entry.reject(error);
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const entry = pending.get(id);
|
|
302
|
+
if (entry) {
|
|
303
|
+
pending.delete(id);
|
|
304
|
+
clearTimeout(entry.timer);
|
|
305
|
+
entry.reject(error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
resolve(state);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
child.stdout?.on('data', chunk => {
|
|
315
|
+
buffer += chunk;
|
|
316
|
+
while (true) {
|
|
317
|
+
const newline = buffer.indexOf('\n');
|
|
318
|
+
if (newline < 0) break;
|
|
319
|
+
const raw = buffer.slice(0, newline).trim();
|
|
320
|
+
buffer = buffer.slice(newline + 1);
|
|
321
|
+
if (!raw) continue;
|
|
322
|
+
let message;
|
|
323
|
+
try { message = JSON.parse(raw); }
|
|
324
|
+
catch {
|
|
325
|
+
if (stderr.length < 2000) stderr += ` invalid helper output: ${raw.slice(0, 500)}`;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (Object.hasOwn(message, 'ready') && !readySettled) {
|
|
329
|
+
void settleReady(message);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const id = Number(message.id);
|
|
333
|
+
const entry = pending.get(id);
|
|
334
|
+
if (!entry) continue;
|
|
335
|
+
pending.delete(id);
|
|
336
|
+
clearTimeout(entry.timer);
|
|
337
|
+
if (message.ok === true) {
|
|
338
|
+
if (state && message.capabilities && typeof message.capabilities === 'object') state.capabilities = { ...message.capabilities };
|
|
339
|
+
entry.resolve(message);
|
|
340
|
+
} else {
|
|
341
|
+
entry.reject(helperError(message, stderr));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
child.stderr?.on('data', chunk => {
|
|
347
|
+
if (stderr.length < 4000) stderr = (stderr + chunk).slice(-4000);
|
|
348
|
+
});
|
|
349
|
+
child.once('error', error => {
|
|
350
|
+
if (!readySettled) {
|
|
351
|
+
readySettled = true;
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
const wrapped = helperError({ kind:error?.code === 'ENOENT' ? 'unsupported' : 'runtime', error:error?.message }, stderr);
|
|
354
|
+
reject(wrapped);
|
|
355
|
+
}
|
|
356
|
+
failPending(error);
|
|
357
|
+
});
|
|
358
|
+
child.once('exit', (code, signal) => {
|
|
359
|
+
const error = new ToolError(`Wayland EIS helper exited unexpectedly (code=${code ?? 'null'}, signal=${signal || 'none'})`);
|
|
360
|
+
if (!readySettled) {
|
|
361
|
+
readySettled = true;
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
reject(helperError({ kind:code === 78 ? 'unsupported' : 'runtime', error:stderr || error.message }, stderr));
|
|
364
|
+
}
|
|
365
|
+
failPending(error);
|
|
366
|
+
if (active === state) active = null;
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return ready;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function closeState(state) {
|
|
374
|
+
if (!state) return;
|
|
375
|
+
if (state.backend === 'xdg-eis') {
|
|
376
|
+
try { state.child?.kill('SIGTERM'); } catch {}
|
|
377
|
+
} else {
|
|
378
|
+
try { state.bus?.disconnect(); } catch {}
|
|
379
|
+
}
|
|
380
|
+
if (active === state) active = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function createWaylandRemoteDesktopSession({
|
|
384
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
385
|
+
parentWindow = '',
|
|
386
|
+
env = process.env,
|
|
387
|
+
loadDbusModule = loadDbus,
|
|
388
|
+
readToken = readRestoreToken,
|
|
389
|
+
persistToken = persistRestoreToken,
|
|
390
|
+
clearToken = clearRestoreToken,
|
|
391
|
+
} = {}) {
|
|
392
|
+
if (!/wayland/i.test(String(env.XDG_SESSION_TYPE || '')) && !env.WAYLAND_DISPLAY) {
|
|
393
|
+
throw new ToolError('Wayland Remote Desktop portal is only used inside a Wayland graphical session');
|
|
394
|
+
}
|
|
395
|
+
const dbus = await loadDbusModule();
|
|
396
|
+
const bus = dbus.sessionBus();
|
|
397
|
+
let state;
|
|
398
|
+
try {
|
|
399
|
+
const uniqueBusName = await waitForBusName(bus, Math.min(timeoutMs, 5000));
|
|
400
|
+
const desktop = await withTimeout(bus.getProxyObject(PORTAL_NAME, PORTAL_PATH), Math.min(timeoutMs, 5000), 'Remote Desktop portal discovery');
|
|
401
|
+
const remote = desktop.getInterface(REMOTE_INTERFACE);
|
|
402
|
+
const context = { dbus, bus, uniqueBusName, remote };
|
|
403
|
+
|
|
404
|
+
const created = await portalRequest(context, 'create_remote_desktop', requestToken => remote.CreateSession({
|
|
405
|
+
handle_token: new dbus.Variant('s', requestToken),
|
|
406
|
+
session_handle_token: new dbus.Variant('s', token('session')),
|
|
407
|
+
}), timeoutMs);
|
|
408
|
+
const session = created.session_handle;
|
|
409
|
+
if (typeof session !== 'string' || !session.startsWith('/')) throw new ToolError('Remote Desktop portal returned no session handle');
|
|
410
|
+
|
|
411
|
+
const restoreToken = await readToken();
|
|
412
|
+
const options = {
|
|
413
|
+
handle_token: new dbus.Variant('s', token('select')),
|
|
414
|
+
types: new dbus.Variant('u', DEVICES),
|
|
415
|
+
persist_mode: new dbus.Variant('u', 2),
|
|
416
|
+
...(restoreToken ? { restore_token: new dbus.Variant('s', restoreToken) } : {}),
|
|
417
|
+
};
|
|
418
|
+
await portalRequest(context, 'select_remote_devices', requestToken => {
|
|
419
|
+
options.handle_token = new dbus.Variant('s', requestToken);
|
|
420
|
+
return remote.SelectDevices(session, options);
|
|
421
|
+
}, timeoutMs);
|
|
422
|
+
|
|
423
|
+
let started;
|
|
424
|
+
try {
|
|
425
|
+
started = await portalRequest(context, 'start_remote_desktop', requestToken => remote.Start(session, String(parentWindow || ''), {
|
|
426
|
+
handle_token: new dbus.Variant('s', requestToken),
|
|
427
|
+
}), timeoutMs);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (restoreToken) await clearToken();
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
const devices = Number(started.devices || 0);
|
|
433
|
+
if ((devices & DEVICES) !== DEVICES) throw new ToolError(`Remote Desktop permission did not grant keyboard and pointer access (devices=${devices})`);
|
|
434
|
+
if (typeof started.restore_token === 'string' && started.restore_token) await persistToken(started.restore_token);
|
|
435
|
+
|
|
436
|
+
state = { dbus, bus, remote, session, devices, streams: Array.isArray(started.streams) ? started.streams : [] };
|
|
437
|
+
try {
|
|
438
|
+
const sessionObject = await bus.getProxyObject(PORTAL_NAME, session);
|
|
439
|
+
const sessionInterface = sessionObject.getInterface(SESSION_INTERFACE);
|
|
440
|
+
sessionInterface.on?.('Closed', () => closeState(state));
|
|
441
|
+
} catch {}
|
|
442
|
+
return state;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
try { bus.disconnect(); } catch {}
|
|
445
|
+
const message = cleanError(error);
|
|
446
|
+
if (/timed out/i.test(message)) {
|
|
447
|
+
throw new ToolError(`GNOME Wayland requires one-time Remote Desktop permission. Approve the system dialog on the computer and retry. ${message}`);
|
|
448
|
+
}
|
|
449
|
+
throw error instanceof ToolError ? error : new ToolError(`Wayland Remote Desktop portal failed: ${message}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function ensureWaylandRemoteDesktop(options = {}) {
|
|
454
|
+
if (active) return active;
|
|
455
|
+
if (!starting) {
|
|
456
|
+
starting = (async () => {
|
|
457
|
+
const injectedLegacyHooks = Boolean(options.loadDbusModule || options.readToken || options.persistToken || options.clearToken);
|
|
458
|
+
if (options.preferEis !== false && !injectedLegacyHooks && eisHelperCandidate(options.env || process.env)) {
|
|
459
|
+
try {
|
|
460
|
+
return await createWaylandEisSession(options);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (!eisUnsupported(error)) throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return createWaylandRemoteDesktopSession(options);
|
|
466
|
+
})().then(state => {
|
|
467
|
+
active = state;
|
|
468
|
+
return state;
|
|
469
|
+
}).finally(() => { starting = null; });
|
|
470
|
+
}
|
|
471
|
+
return starting;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function keysymForToken(tokenValue) {
|
|
475
|
+
const token = String(tokenValue || '').trim();
|
|
476
|
+
const normalized = token.toLowerCase();
|
|
477
|
+
const named = {
|
|
478
|
+
ctrl: 0xffe3, control: 0xffe3, shift: 0xffe1, alt: 0xffe9, option: 0xffe9,
|
|
479
|
+
super: 0xffeb, meta: 0xffeb, cmd: 0xffeb, command: 0xffeb,
|
|
480
|
+
enter: 0xff0d, return: 0xff0d, esc: 0xff1b, escape: 0xff1b, tab: 0xff09,
|
|
481
|
+
backspace: 0xff08, delete: 0xffff, up: 0xff52, down: 0xff54, left: 0xff51, right: 0xff53,
|
|
482
|
+
home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56, space: 0x20,
|
|
483
|
+
};
|
|
484
|
+
if (named[normalized]) return named[normalized];
|
|
485
|
+
const fn = normalized.match(/^f(\d{1,2})$/);
|
|
486
|
+
if (fn) {
|
|
487
|
+
const number = Number(fn[1]);
|
|
488
|
+
if (number >= 1 && number <= 35) return 0xffbd + number;
|
|
489
|
+
}
|
|
490
|
+
const chars = [...token];
|
|
491
|
+
if (chars.length !== 1) throw new ToolError(`Unsupported keyboard token: ${token}`);
|
|
492
|
+
const codePoint = chars[0].codePointAt(0);
|
|
493
|
+
return codePoint <= 0xff ? codePoint : (0x01000000 | codePoint);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function notifyKeysym(state, keysym, keyState) {
|
|
497
|
+
if (state.backend === 'xdg-eis') throw new ToolError('EIS 1.2 keyboard input requires evdev keycodes, not X11 keysyms');
|
|
498
|
+
await state.remote.NotifyKeyboardKeysym(state.session, {}, keysym, keyState);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function notifyKeycode(state, keycode, keyState) {
|
|
502
|
+
if (state.backend === 'xdg-eis') {
|
|
503
|
+
await state.send({ op:'key', keycode:Number(keycode), pressed:keyState === 1 });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
await state.remote.NotifyKeyboardKeycode(state.session, {}, keycode, keyState);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function keycodeForToken(tokenValue) {
|
|
510
|
+
const token = String(tokenValue || '').trim().toLowerCase();
|
|
511
|
+
const named = {
|
|
512
|
+
esc:1, escape:1,
|
|
513
|
+
'1':2, '2':3, '3':4, '4':5, '5':6, '6':7, '7':8, '8':9, '9':10, '0':11,
|
|
514
|
+
minus:12, '-':12, equal:13, '=':13, backspace:14, tab:15,
|
|
515
|
+
q:16, w:17, e:18, r:19, t:20, y:21, u:22, i:23, o:24, p:25,
|
|
516
|
+
'[':26, ']':27, enter:28, return:28, ctrl:29, control:29,
|
|
517
|
+
a:30, s:31, d:32, f:33, g:34, h:35, j:36, k:37, l:38,
|
|
518
|
+
';':39, "'":40, grave:41, backtick:41, '`':41, shift:42, '\\':43,
|
|
519
|
+
z:44, x:45, c:46, v:47, b:48, n:49, m:50, ',':51, '.':52, '/':53,
|
|
520
|
+
alt:56, option:56, space:57,
|
|
521
|
+
f1:59, f2:60, f3:61, f4:62, f5:63, f6:64, f7:65, f8:66, f9:67, f10:68,
|
|
522
|
+
f11:87, f12:88,
|
|
523
|
+
home:102, up:103, pageup:104, left:105, right:106, end:107, down:108, pagedown:109,
|
|
524
|
+
insert:110, delete:111,
|
|
525
|
+
super:125, meta:125, cmd:125, command:125,
|
|
526
|
+
};
|
|
527
|
+
return Number.isInteger(named[token]) ? named[token] : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function notifyToken(state, token, keyState) {
|
|
531
|
+
if (state.backend === 'xdg-eis') {
|
|
532
|
+
const keycode = keycodeForToken(token);
|
|
533
|
+
if (!Number.isInteger(keycode)) throw new ToolError(`Unsupported EIS keyboard token: ${token}`);
|
|
534
|
+
return notifyKeycode(state, keycode, keyState);
|
|
535
|
+
}
|
|
536
|
+
return notifyKeysym(state, keysymForToken(token), keyState);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function portalShortcut(shortcut, options = {}) {
|
|
540
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
541
|
+
const tokens = String(shortcut || '').split('+').map(value => value.trim()).filter(Boolean);
|
|
542
|
+
if (!tokens.length) throw new ToolError('shortcut or key is required');
|
|
543
|
+
const modifiers = tokens.slice(0, -1);
|
|
544
|
+
const key = tokens.at(-1);
|
|
545
|
+
for (const modifier of modifiers) await notifyToken(state, modifier, 1);
|
|
546
|
+
await notifyToken(state, key, 1);
|
|
547
|
+
await notifyToken(state, key, 0);
|
|
548
|
+
for (const modifier of [...modifiers].reverse()) await notifyToken(state, modifier, 0);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function eisStrokeForChar(char) {
|
|
552
|
+
if (char === '\n') return { keycode:28, shift:false };
|
|
553
|
+
if (char === '\t') return { keycode:15, shift:false };
|
|
554
|
+
if (char === ' ') return { keycode:57, shift:false };
|
|
555
|
+
if (/^[a-z]$/.test(char)) return { keycode:keycodeForToken(char), shift:false };
|
|
556
|
+
if (/^[A-Z]$/.test(char)) return { keycode:keycodeForToken(char), shift:true };
|
|
557
|
+
if (/^[0-9]$/.test(char)) return { keycode:keycodeForToken(char), shift:false };
|
|
558
|
+
const plain = { '-':12, '=':13, '[':26, ']':27, ';':39, "'":40, '`':41, '\\':43, ',':51, '.':52, '/':53 };
|
|
559
|
+
if (Number.isInteger(plain[char])) return { keycode:plain[char], shift:false };
|
|
560
|
+
const shifted = { '!':2, '@':3, '#':4, '$':5, '%':6, '^':7, '&':8, '*':9, '(':10, ')':11, '_':12, '+':13, '{':26, '}':27, ':':39, '"':40, '~':41, '|':43, '<':51, '>':52, '?':53 };
|
|
561
|
+
if (Number.isInteger(shifted[char])) return { keycode:shifted[char], shift:true };
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function portalTypeText(value, { delayMs = 1, timeoutMs, state: suppliedState } = {}) {
|
|
566
|
+
const state = suppliedState || await ensureWaylandRemoteDesktop({ timeoutMs });
|
|
567
|
+
for (const char of String(value ?? '')) {
|
|
568
|
+
if (state.backend === 'xdg-eis') {
|
|
569
|
+
const stroke = eisStrokeForChar(char);
|
|
570
|
+
if (!stroke) throw new ToolError('EIS 1.2 direct typing supports ASCII only; use accessibility or clipboard-paste input for Unicode text');
|
|
571
|
+
if (stroke.shift) await notifyKeycode(state, 42, 1);
|
|
572
|
+
await notifyKeycode(state, stroke.keycode, 1);
|
|
573
|
+
await notifyKeycode(state, stroke.keycode, 0);
|
|
574
|
+
if (stroke.shift) await notifyKeycode(state, 42, 0);
|
|
575
|
+
} else {
|
|
576
|
+
const keysym = char === '\n' ? 0xff0d : char === '\t' ? 0xff09 : keysymForToken(char);
|
|
577
|
+
await notifyKeysym(state, keysym, 1);
|
|
578
|
+
await notifyKeysym(state, keysym, 0);
|
|
579
|
+
}
|
|
580
|
+
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, Math.min(1000, delayMs)));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function portalPointerMotion(dx, dy, options = {}) {
|
|
585
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
586
|
+
if (state.backend === 'xdg-eis') {
|
|
587
|
+
await state.send({ op:'motion', dx:Number(dx), dy:Number(dy) });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
await state.remote.NotifyPointerMotion(state.session, {}, Number(dx), Number(dy));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function portalPointerButton(button, pressed, options = {}) {
|
|
594
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
595
|
+
const code = String(button || 'left').toLowerCase() === 'right' ? 0x111 : String(button || 'left').toLowerCase() === 'middle' ? 0x112 : 0x110;
|
|
596
|
+
if (state.backend === 'xdg-eis') {
|
|
597
|
+
await state.send({ op:'button', button:code, pressed:Boolean(pressed) });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await state.remote.NotifyPointerButton(state.session, {}, code, pressed ? 1 : 0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function portalScroll(dx, dy, options = {}) {
|
|
604
|
+
const state = options.state || await ensureWaylandRemoteDesktop(options);
|
|
605
|
+
const steps = delta => delta ? Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / 120)) : 0;
|
|
606
|
+
const x = steps(dx), y = steps(dy);
|
|
607
|
+
if (state.backend === 'xdg-eis') {
|
|
608
|
+
await state.send({ op:'scroll', dx:x * 120, dy:y * 120 });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (x) await state.remote.NotifyPointerAxisDiscrete(state.session, {}, 1, x);
|
|
612
|
+
if (y) await state.remote.NotifyPointerAxisDiscrete(state.session, {}, 0, y);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function hasWaylandRemoteDesktopRestoreToken() {
|
|
616
|
+
return existsSync(TOKEN_FILE);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function waylandPortalCandidate(env = process.env, platform = process.platform) {
|
|
620
|
+
const wayland = /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
|
|
621
|
+
if (!wayland || platform !== 'linux') return false;
|
|
622
|
+
if (env.DBUS_SESSION_BUS_ADDRESS) return true;
|
|
623
|
+
return typeof process.getuid === 'function' && existsSync(path.join('/run/user', String(process.getuid()), 'bus'));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function hasWaylandRemoteDesktopGrant() {
|
|
627
|
+
return Boolean(active) || existsSync(TOKEN_FILE);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function closeWaylandRemoteDesktop() {
|
|
631
|
+
const state = active;
|
|
632
|
+
active = null;
|
|
633
|
+
if (!state) return;
|
|
634
|
+
if (state.backend === 'xdg-eis') {
|
|
635
|
+
try { await state.send({ op:'close' }, 1500); } catch {}
|
|
636
|
+
try { state.child?.stdin?.end(); } catch {}
|
|
637
|
+
if (state.child && state.child.exitCode == null) {
|
|
638
|
+
await Promise.race([
|
|
639
|
+
new Promise(resolve => state.child.once('exit', resolve)),
|
|
640
|
+
new Promise(resolve => setTimeout(resolve, 500)),
|
|
641
|
+
]);
|
|
642
|
+
}
|
|
643
|
+
if (state.child && state.child.exitCode == null) {
|
|
644
|
+
try { state.child.kill('SIGTERM'); } catch {}
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const sessionObject = await state.bus.getProxyObject(PORTAL_NAME, state.session);
|
|
650
|
+
const sessionInterface = sessionObject.getInterface(SESSION_INTERFACE);
|
|
651
|
+
await sessionInterface.Close();
|
|
652
|
+
} catch {}
|
|
653
|
+
closeState(state);
|
|
654
|
+
}
|