@remcp/runtime 0.2.108 → 0.2.110
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 +13 -0
- package/README.md +9 -4
- package/package.json +1 -1
- package/src/extended/browser.mjs +89 -22
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +11 -75
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +582 -210
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +12 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
6
|
import { fileURLToPath } from 'node:url';
|
|
4
7
|
|
|
5
8
|
const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
@@ -133,6 +136,115 @@ function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
|
|
|
133
136
|
};
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
async function openDirectoryNoFollow(directory) {
|
|
140
|
+
if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
141
|
+
const info = await lstat(directory);
|
|
142
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('screenshot path parent is not a safe directory');
|
|
143
|
+
return { anchor:directory, close:async() => {} };
|
|
144
|
+
}
|
|
145
|
+
const descriptor = directory.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
|
|
146
|
+
const directories = [];
|
|
147
|
+
let current;
|
|
148
|
+
try {
|
|
149
|
+
if (descriptor) {
|
|
150
|
+
const base = await open(`${descriptor[1]}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
151
|
+
directories.push(base);
|
|
152
|
+
current = `/proc/self/fd/${base.fd}`;
|
|
153
|
+
} else {
|
|
154
|
+
const root = path.parse(directory).root;
|
|
155
|
+
current = root;
|
|
156
|
+
const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
157
|
+
if (!parts.length) {
|
|
158
|
+
const handle = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
159
|
+
return { anchor:`/proc/self/fd/${handle.fd}`, close:() => handle.close() };
|
|
160
|
+
}
|
|
161
|
+
for (const part of parts) {
|
|
162
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
163
|
+
const info = await handle.stat();
|
|
164
|
+
if (!info.isDirectory()) {
|
|
165
|
+
await handle.close().catch(() => {});
|
|
166
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
167
|
+
}
|
|
168
|
+
directories.push(handle);
|
|
169
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
170
|
+
}
|
|
171
|
+
const handle = directories.pop();
|
|
172
|
+
return {
|
|
173
|
+
anchor:current,
|
|
174
|
+
close:async() => {
|
|
175
|
+
try { await handle.close(); }
|
|
176
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
for (const part of (descriptor?.[2] || '').split(path.sep).filter(Boolean)) {
|
|
181
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
182
|
+
const info = await handle.stat();
|
|
183
|
+
if (!info.isDirectory()) {
|
|
184
|
+
await handle.close().catch(() => {});
|
|
185
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
186
|
+
}
|
|
187
|
+
directories.push(handle);
|
|
188
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
189
|
+
}
|
|
190
|
+
const handle = directories.pop();
|
|
191
|
+
return {
|
|
192
|
+
anchor:current,
|
|
193
|
+
close:async() => {
|
|
194
|
+
try { await handle.close(); }
|
|
195
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
for (const entry of directories.reverse()) await entry.close().catch(() => {});
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function copyPortalFile(source, destination) {
|
|
205
|
+
let sourceParent;
|
|
206
|
+
let destinationParent;
|
|
207
|
+
let input;
|
|
208
|
+
let output;
|
|
209
|
+
let temporary;
|
|
210
|
+
try {
|
|
211
|
+
sourceParent = await openDirectoryNoFollow(path.dirname(source));
|
|
212
|
+
destinationParent = await openDirectoryNoFollow(path.dirname(destination));
|
|
213
|
+
input = await open(path.join(sourceParent.anchor, path.basename(source)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
214
|
+
const info = await input.stat();
|
|
215
|
+
if (!info.isFile()) throw new Error('desktop portal returned a non-regular screenshot file');
|
|
216
|
+
const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
|
|
217
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
218
|
+
if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
|
|
219
|
+
temporary = path.join(destinationParent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
220
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
221
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
222
|
+
let offset = 0;
|
|
223
|
+
for (;;) {
|
|
224
|
+
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
225
|
+
if (!bytesRead) break;
|
|
226
|
+
let written = 0;
|
|
227
|
+
while (written < bytesRead) {
|
|
228
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
229
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying the desktop portal screenshot');
|
|
230
|
+
written += result.bytesWritten;
|
|
231
|
+
}
|
|
232
|
+
offset += bytesRead;
|
|
233
|
+
}
|
|
234
|
+
await output.sync();
|
|
235
|
+
await output.close();
|
|
236
|
+
output = null;
|
|
237
|
+
await rename(temporary, destinationPath);
|
|
238
|
+
temporary = null;
|
|
239
|
+
} finally {
|
|
240
|
+
if (output) await output.close().catch(() => {});
|
|
241
|
+
if (temporary) await unlink(temporary).catch(() => {});
|
|
242
|
+
if (input) await input.close().catch(() => {});
|
|
243
|
+
if (destinationParent) await destinationParent.close().catch(() => {});
|
|
244
|
+
if (sourceParent) await sourceParent.close().catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
136
248
|
export async function capturePortalScreenshot(destination, {
|
|
137
249
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
138
250
|
loadDbus = () => import('@jellybrick/dbus-next'),
|
|
@@ -184,12 +296,9 @@ export async function capturePortalScreenshot(destination, {
|
|
|
184
296
|
const uri = results?.uri?.value;
|
|
185
297
|
if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
|
|
186
298
|
const sourceUrl = new URL(uri);
|
|
187
|
-
if (sourceUrl.protocol !== 'file:') throw new Error(
|
|
299
|
+
if (sourceUrl.protocol !== 'file:' || (sourceUrl.hostname && sourceUrl.hostname !== 'localhost')) throw new Error('desktop portal returned an unsupported file URI');
|
|
188
300
|
const source = fileURLToPath(sourceUrl);
|
|
189
|
-
await
|
|
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(() => {});
|
|
301
|
+
await copyPortalFile(source, destination);
|
|
193
302
|
return destination;
|
|
194
303
|
} catch (error) {
|
|
195
304
|
const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
|
package/src/sessions.mjs
CHANGED
|
@@ -190,6 +190,7 @@ export function createSearchSession({ type, pattern, path, filePattern }) {
|
|
|
190
190
|
error: null,
|
|
191
191
|
warning: null,
|
|
192
192
|
cancel: null,
|
|
193
|
+
release: null,
|
|
193
194
|
waiters: [],
|
|
194
195
|
lastActivityAt: Date.now(),
|
|
195
196
|
};
|
|
@@ -209,6 +210,9 @@ export function finishSearchSession(session, status, error = null) {
|
|
|
209
210
|
session.error = error ? String(error) : null;
|
|
210
211
|
session.finishedAt = Date.now();
|
|
211
212
|
session.cancel = null;
|
|
213
|
+
const release = session.release;
|
|
214
|
+
session.release = null;
|
|
215
|
+
if (release) void Promise.resolve(release()).catch(() => {});
|
|
212
216
|
notify(session);
|
|
213
217
|
}
|
|
214
218
|
|