@remcp/runtime 0.2.32 → 0.2.33
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 +7 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/catalog.mjs +1 -1
- package/src/screenshot-portal.mjs +205 -0
- package/src/tools/files.mjs +27 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.33
|
|
4
|
+
|
|
5
|
+
- Linux Wayland screenshots now use the standard XDG Desktop Portal before compositor-specific
|
|
6
|
+
command-line fallbacks. The runtime subscribes to the portal response before requesting the
|
|
7
|
+
capture, handles fast and legacy request handles without losing the signal, respects an explicit
|
|
8
|
+
cancellation, and cleans the portal-created intermediate PNG after copying it.
|
|
9
|
+
|
|
3
10
|
## 0.2.30
|
|
4
11
|
|
|
5
12
|
- **Unrestricted mode.** `REMCP_RUNTIME_UNRESTRICTED=1` (or `"unrestricted": true` in `runtime.json`,
|
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ MCP server.
|
|
|
190
190
|
| | Desktop Commander 0.2.50 | ReMCP runtime 0.2.0 |
|
|
191
191
|
| --- | --- | --- |
|
|
192
192
|
| Tools | 26, including config mutators and document tooling | 35, including binary transfer, archives, screenshots, and diffs |
|
|
193
|
-
| Runtime dependencies | 34 (Supabase, Puppeteer/md-to-pdf, `sharp`, `exceljs`, Tiptap, ripgrep download) |
|
|
193
|
+
| Runtime dependencies | 34 (Supabase, Puppeteer/md-to-pdf, `sharp`, `exceljs`, Tiptap, ripgrep download) | 2 direct (`@modelcontextprotocol/sdk`, `@jellybrick/dbus-next`) |
|
|
194
194
|
| Install scripts | `postinstall` posts an install payload that ignores the telemetry setting | none |
|
|
195
195
|
| Telemetry | opt-out, 51 event names, remote feature flags, A/B assignment, third-party processor | opt-out, whitelisted event schema, no endpoint, no flags |
|
|
196
196
|
| Install size | 3.78 MB unpacked, 249 files | ~110 kB unpacked, 20 files |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remcp/runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.33",
|
|
4
4
|
"description": "First-party ReMCP local device runtime: file, search, terminal and process tools over MCP for computers paired with ReMCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"test": "node --test test/*.test.mjs"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@jellybrick/dbus-next": "^0.11.3",
|
|
27
28
|
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
28
29
|
},
|
|
29
30
|
"repository": {
|
package/src/catalog.mjs
CHANGED
|
@@ -516,7 +516,7 @@ export const toolDefinitions = [
|
|
|
516
516
|
{
|
|
517
517
|
name: 'take_screenshot',
|
|
518
518
|
title: 'Take screenshot',
|
|
519
|
-
description: 'Capture the screen of the paired computer and return it as an image, for GUI work, visual checks, and demonstrating what is on screen. Uses grim, gnome-screenshot, spectacle, scrot, ImageMagick import, screencapture, or PowerShell depending on the platform.',
|
|
519
|
+
description: 'Capture the screen of the paired computer and return it as an image, for GUI work, visual checks, and demonstrating what is on screen. Uses the standard desktop screenshot portal on Wayland, with grim, gnome-screenshot, spectacle, scrot, ImageMagick import, screencapture, or PowerShell fallbacks depending on the platform.',
|
|
520
520
|
inputSchema: {
|
|
521
521
|
type: 'object',
|
|
522
522
|
properties: {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { copyFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
6
|
+
const PORTAL_PATH = '/org/freedesktop/portal/desktop';
|
|
7
|
+
const SCREENSHOT_INTERFACE = 'org.freedesktop.portal.Screenshot';
|
|
8
|
+
const REQUEST_INTERFACE = 'org.freedesktop.portal.Request';
|
|
9
|
+
const DBUS_NAME = 'org.freedesktop.DBus';
|
|
10
|
+
const DBUS_PATH = '/org/freedesktop/DBus';
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
12
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
13
|
+
|
|
14
|
+
export function isWaylandSession(env = process.env) {
|
|
15
|
+
return /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanError(error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'unknown error';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function errorWithCode(message, code) {
|
|
24
|
+
const error = new Error(message);
|
|
25
|
+
error.code = code;
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
32
|
+
Promise.resolve(promise).then(
|
|
33
|
+
value => { clearTimeout(timer); resolve(value); },
|
|
34
|
+
error => { clearTimeout(timer); reject(error); },
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function waitForBusName(bus, timeoutMs) {
|
|
40
|
+
if (typeof bus.name === 'string' && bus.name.startsWith(':')) return Promise.resolve(bus.name);
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let timer;
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
if (timer) clearTimeout(timer);
|
|
45
|
+
bus.off?.('connect', onConnect);
|
|
46
|
+
bus.off?.('error', onError);
|
|
47
|
+
};
|
|
48
|
+
const onConnect = () => {
|
|
49
|
+
if (typeof bus.name !== 'string' || !bus.name.startsWith(':')) {
|
|
50
|
+
cleanup();
|
|
51
|
+
reject(new Error('session D-Bus connected without a unique bus name'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const name = bus.name;
|
|
55
|
+
cleanup();
|
|
56
|
+
resolve(name);
|
|
57
|
+
};
|
|
58
|
+
const onError = error => {
|
|
59
|
+
cleanup();
|
|
60
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
61
|
+
};
|
|
62
|
+
bus.once('connect', onConnect);
|
|
63
|
+
bus.once('error', onError);
|
|
64
|
+
timer = setTimeout(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
reject(new Error(`session D-Bus did not connect within ${timeoutMs} ms`));
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function expectedRequestPath(uniqueBusName, token) {
|
|
72
|
+
// xdg-desktop-portal 0.9+ derives the request object path from the caller's unique bus name and
|
|
73
|
+
// handle_token. Subscribing before Screenshot() closes the documented race where Response can be
|
|
74
|
+
// emitted before the method reply reaches us.
|
|
75
|
+
const sender = uniqueBusName.replace(/^:/, '').replace(/\./g, '_');
|
|
76
|
+
return `/org/freedesktop/portal/desktop/request/${sender}/${token}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchRule() {
|
|
80
|
+
// Deliberately omit the path. Current portals return the predictable path above, while older
|
|
81
|
+
// portals may return a different handle. A short-lived broad match lets us buffer either path and
|
|
82
|
+
// still filter the response to this request in-process.
|
|
83
|
+
return `type='signal',sender='${PORTAL_NAME}',interface='${REQUEST_INTERFACE}',member='Response'`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dbusMatchMessage(dbus, member, rule) {
|
|
87
|
+
return new dbus.Message({
|
|
88
|
+
destination: DBUS_NAME,
|
|
89
|
+
path: DBUS_PATH,
|
|
90
|
+
interface: DBUS_NAME,
|
|
91
|
+
member,
|
|
92
|
+
signature: 's',
|
|
93
|
+
body: [rule],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
|
|
98
|
+
let targetPath = initialPath;
|
|
99
|
+
let settled = false;
|
|
100
|
+
const buffered = new Map();
|
|
101
|
+
let resolveResponse;
|
|
102
|
+
const promise = new Promise(resolve => { resolveResponse = resolve; });
|
|
103
|
+
const finish = value => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
resolveResponse(value);
|
|
108
|
+
};
|
|
109
|
+
const onMessage = message => {
|
|
110
|
+
if (message?.type !== dbus.MessageType.SIGNAL) return;
|
|
111
|
+
if (message.interface !== REQUEST_INTERFACE || message.member !== 'Response') return;
|
|
112
|
+
if (typeof message.path !== 'string') return;
|
|
113
|
+
const value = {
|
|
114
|
+
response: Number(message.body?.[0]),
|
|
115
|
+
results: message.body?.[1],
|
|
116
|
+
};
|
|
117
|
+
if (message.path === targetPath) finish(value);
|
|
118
|
+
else buffered.set(message.path, value);
|
|
119
|
+
};
|
|
120
|
+
const timer = setTimeout(() => finish({ timeout: true }), timeoutMs);
|
|
121
|
+
bus.on('message', onMessage);
|
|
122
|
+
return {
|
|
123
|
+
promise,
|
|
124
|
+
setPath(path) {
|
|
125
|
+
targetPath = path;
|
|
126
|
+
const pending = buffered.get(path);
|
|
127
|
+
if (pending) finish(pending);
|
|
128
|
+
},
|
|
129
|
+
stop() {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
bus.off?.('message', onMessage);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function capturePortalScreenshot(destination, {
|
|
137
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
138
|
+
loadDbus = () => import('@jellybrick/dbus-next'),
|
|
139
|
+
} = {}) {
|
|
140
|
+
const deadline = Date.now() + timeoutMs;
|
|
141
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
142
|
+
let dbus;
|
|
143
|
+
let bus;
|
|
144
|
+
let collector;
|
|
145
|
+
let rule;
|
|
146
|
+
let matchInstalled = false;
|
|
147
|
+
try {
|
|
148
|
+
dbus = await loadDbus();
|
|
149
|
+
if (typeof dbus.sessionBus !== 'function' || typeof dbus.Variant !== 'function' ||
|
|
150
|
+
typeof dbus.Message !== 'function' || typeof dbus.MessageType?.SIGNAL !== 'number') {
|
|
151
|
+
throw new Error('D-Bus client module is missing required exports');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
bus = dbus.sessionBus();
|
|
155
|
+
const uniqueBusName = await waitForBusName(bus, Math.min(CONNECT_TIMEOUT_MS, remaining()));
|
|
156
|
+
const desktop = await withTimeout(bus.getProxyObject(PORTAL_NAME, PORTAL_PATH), remaining(), 'desktop portal discovery');
|
|
157
|
+
const screenshot = desktop.getInterface(SCREENSHOT_INTERFACE);
|
|
158
|
+
const token = `remcp_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
159
|
+
.replace(/[^A-Za-z0-9_]/g, '_');
|
|
160
|
+
const predictedHandle = expectedRequestPath(uniqueBusName, token);
|
|
161
|
+
|
|
162
|
+
// Install the D-Bus match and listener *before* calling Screenshot. The portal can emit Response
|
|
163
|
+
// very quickly, and attaching a proxy listener only after the method returns can lose it.
|
|
164
|
+
rule = matchRule();
|
|
165
|
+
collector = createResponseCollector(bus, dbus, predictedHandle, remaining());
|
|
166
|
+
await withTimeout(bus.call(dbusMatchMessage(dbus, 'AddMatch', rule)), remaining(), 'desktop portal signal subscription');
|
|
167
|
+
matchInstalled = true;
|
|
168
|
+
|
|
169
|
+
const handle = await withTimeout(screenshot.Screenshot('', {
|
|
170
|
+
handle_token: new dbus.Variant('s', token),
|
|
171
|
+
interactive: new dbus.Variant('b', false),
|
|
172
|
+
}), remaining(), 'desktop portal screenshot request');
|
|
173
|
+
if (typeof handle !== 'string' || !handle.startsWith('/')) {
|
|
174
|
+
throw new Error('desktop portal returned an invalid request handle');
|
|
175
|
+
}
|
|
176
|
+
collector.setPath(handle);
|
|
177
|
+
|
|
178
|
+
const responseResult = await collector.promise;
|
|
179
|
+
if (responseResult.timeout) throw new Error(`desktop portal did not respond within ${timeoutMs} ms`);
|
|
180
|
+
const { response, results } = responseResult;
|
|
181
|
+
if (response === 1) throw errorWithCode('screen capture permission was cancelled', 'PORTAL_CANCELLED');
|
|
182
|
+
if (response !== 0) throw new Error(`desktop portal returned response ${response}`);
|
|
183
|
+
|
|
184
|
+
const uri = results?.uri?.value;
|
|
185
|
+
if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
|
|
186
|
+
const sourceUrl = new URL(uri);
|
|
187
|
+
if (sourceUrl.protocol !== 'file:') throw new Error(`desktop portal returned unsupported URI scheme ${sourceUrl.protocol}`);
|
|
188
|
+
const source = fileURLToPath(sourceUrl);
|
|
189
|
+
await copyFile(source, destination);
|
|
190
|
+
// GNOME commonly writes the portal result to ~/Pictures. The runtime owns this capture, so remove
|
|
191
|
+
// that portal-created intermediate after copying it to the caller-selected temporary location.
|
|
192
|
+
if (source !== destination) await rm(source, { force: true }).catch(() => {});
|
|
193
|
+
return destination;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
|
|
196
|
+
if (error && typeof error === 'object' && typeof error.code === 'string') wrapped.code = error.code;
|
|
197
|
+
throw wrapped;
|
|
198
|
+
} finally {
|
|
199
|
+
collector?.stop();
|
|
200
|
+
if (bus && matchInstalled && rule) {
|
|
201
|
+
try { await bus.call(dbusMatchMessage(dbus, 'RemoveMatch', rule)); } catch {}
|
|
202
|
+
}
|
|
203
|
+
try { bus?.disconnect(); } catch {}
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/tools/files.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
|
9
9
|
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
10
10
|
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
11
11
|
import { describeFilesystemFailure } from '../permissions.mjs';
|
|
12
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
12
13
|
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
13
14
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
14
15
|
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
@@ -936,15 +937,14 @@ function screenshotAdvice(attempts, { platform = process.platform, env = process
|
|
|
936
937
|
if (!env.DISPLAY && !wayland) {
|
|
937
938
|
return `This computer has no graphical session (no DISPLAY and no Wayland display), so there is nothing to capture — servers and containers usually have none.`;
|
|
938
939
|
}
|
|
939
|
-
// GNOME on Wayland is its own case: Mutter does not expose wlr-screencopy, so grim cannot
|
|
940
|
-
// it
|
|
941
|
-
//
|
|
942
|
-
// portal and asks the person once), so the message names that instead of sending people to grim.
|
|
940
|
+
// GNOME on Wayland is its own case: Mutter does not expose wlr-screencopy, so grim cannot
|
|
941
|
+
// capture it. ReMCP tries the compositor-supported XDG Desktop Portal first; local screenshot
|
|
942
|
+
// commands remain fallbacks for environments where the portal is unavailable.
|
|
943
943
|
if (wayland && /gnome/i.test(String(env.XDG_CURRENT_DESKTOP || ''))) {
|
|
944
|
-
return `Could not capture the screen on GNOME Wayland (${attempts.join('; ') || 'no capture
|
|
944
|
+
return `Could not capture the screen on GNOME Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first, which is GNOME's supported screenshot API. Make sure xdg-desktop-portal and xdg-desktop-portal-gnome are installed and the agent is running inside the signed-in user's graphical session; command-line capture helpers are only fallbacks.`;
|
|
945
945
|
}
|
|
946
946
|
if (wayland) {
|
|
947
|
-
return `Could not capture the screen on Wayland (${attempts.join('; ') || 'no capture
|
|
947
|
+
return `Could not capture the screen on Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first. Verify xdg-desktop-portal is running; on wlroots compositors, \`grim\` is also supported as a fallback.`;
|
|
948
948
|
}
|
|
949
949
|
return `Could not capture the screen. Install one of grim, gnome-screenshot, spectacle, scrot, or ImageMagick import (tried: ${attempts.join('; ') || 'none available'}).`;
|
|
950
950
|
}
|
|
@@ -969,11 +969,27 @@ export async function takeScreenshotTool(args) {
|
|
|
969
969
|
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
|
|
970
970
|
attempts.push(`powershell: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
|
|
971
971
|
} else {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
972
|
+
// GNOME and other modern Wayland compositors intentionally prevent X11/wlroots
|
|
973
|
+
// screenshot commands from reading the desktop. The freedesktop Screenshot portal
|
|
974
|
+
// is the compositor-supported API and must run before command-line fallbacks.
|
|
975
|
+
if (process.platform === 'linux' && isWaylandSession()) {
|
|
976
|
+
try {
|
|
977
|
+
await capturePortalScreenshot(file);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
|
|
980
|
+
attempts.push(message);
|
|
981
|
+
if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
|
|
982
|
+
fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (!await pathExists(file)) {
|
|
987
|
+
for (const candidate of SCREENSHOT_COMMANDS) {
|
|
988
|
+
if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
|
|
989
|
+
const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
|
|
990
|
+
if (result.status === 0 && await pathExists(file)) break;
|
|
991
|
+
attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
|
|
992
|
+
}
|
|
977
993
|
}
|
|
978
994
|
}
|
|
979
995
|
if (!await pathExists(file)) {
|