dsh-plugin-read-image-preview 1.4.1 → 1.6.0
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/LICENSE +21 -21
- package/README.md +129 -33
- package/client.js +2594 -718
- package/index.js +584 -119
- package/package.json +11 -2
package/index.js
CHANGED
|
@@ -1,36 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host half of the read_image preview plugin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* it on two plugin-private loopback routes:
|
|
4
|
+
* Tool-result images (read_image included) reach the page as durable
|
|
5
|
+
* attachments through the session's own loader, so this half reads no
|
|
6
|
+
* tool-supplied path. What it serves is the screenshot workspace's own
|
|
7
|
+
* directory, over plugin-private loopback routes:
|
|
9
8
|
*
|
|
10
|
-
* - `GET /read-image-preview
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* - `GET /read-image-preview/<token>` — a saved shot's bytes, for `<img src>`.
|
|
10
|
+
* The URL carries an opaque token minted by the list/save routes rather than
|
|
11
|
+
* a path, and only the four bitmap containers are served.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
* the file extension — is what goes out in `Content-Type`, so a file that
|
|
16
|
-
* happens to be SVG or HTML can never be negotiated as script. A URL carries an
|
|
17
|
-
* opaque token rather than a path, so the page never hands the host an arbitrary
|
|
18
|
-
* path to read back.
|
|
19
|
-
*
|
|
20
|
-
* Both routes are plain HTTP on the plugin's own prefix; nothing is published
|
|
13
|
+
* Every route is plain HTTP on the plugin's own prefix; nothing is published
|
|
21
14
|
* into the Cordis service namespace, so a sibling plugin cannot collide with it.
|
|
22
15
|
*
|
|
16
|
+
* The screenshot workspace is also why the file owns a directory, a
|
|
17
|
+
* subprocess, and a timer:
|
|
18
|
+
*
|
|
19
|
+
* - `POST /read-image-preview/capture` grabs the desktop (every monitor) and
|
|
20
|
+
* answers with the PNG, so the browser can crop and annotate it;
|
|
21
|
+
* - `POST /read-image-preview/save` writes an annotated PNG into the screenshot
|
|
22
|
+
* directory and answers with its path plus a capability URL;
|
|
23
|
+
* - `GET /read-image-preview/list`, `POST /read-image-preview/delete`, and
|
|
24
|
+
* `POST /read-image-preview/cleanup` drive the 截图 page and the retention
|
|
25
|
+
* policy, which also runs on a timer so the directory stays bounded.
|
|
26
|
+
*
|
|
27
|
+
* Everything the browser may reach goes through the composition's `connection`
|
|
28
|
+
* trust check first (`requestRejection`), because these routes write files and
|
|
29
|
+
* start a screen grab; the read-only shot image route keeps its token-only
|
|
30
|
+
* authorization. Grabs land in the OS temp directory and are unlinked as soon
|
|
31
|
+
* as they are read back.
|
|
32
|
+
*
|
|
23
33
|
* @module dsh-plugin-read-image-preview
|
|
24
34
|
*/
|
|
25
35
|
|
|
36
|
+
import { execFile, spawn } from 'node:child_process'
|
|
37
|
+
import fs from 'node:fs/promises'
|
|
38
|
+
import os from 'node:os'
|
|
39
|
+
import path from 'node:path'
|
|
40
|
+
import z from '@deepseek-ai/schemastery'
|
|
41
|
+
|
|
26
42
|
/** Route prefix owned by this plugin. */
|
|
27
43
|
const ROUTE = '/read-image-preview'
|
|
28
44
|
/** Refuse to buffer an image larger than this, mirroring the read tool's own bound. */
|
|
29
45
|
const MAX_BYTES = 268435456
|
|
30
|
-
/** Header window large enough for a JPEG's leading segments and every other header. */
|
|
31
|
-
const HEAD_BYTES = 65536
|
|
32
46
|
/** Tokens are cheap; a card's worth of history is all anyone re-requests. */
|
|
33
|
-
const MAX_TOKENS =
|
|
47
|
+
const MAX_TOKENS = 256
|
|
48
|
+
/** An annotated multi-monitor grab is a few MB; this bounds what a save may carry. */
|
|
49
|
+
const MAX_SAVE_BYTES = 64 * 1024 * 1024
|
|
50
|
+
/**
|
|
51
|
+
* A grab command that has not produced a file by now is treated as hung. The
|
|
52
|
+
* Windows fallback path compiles its P/Invoke helper, which alone can take the
|
|
53
|
+
* better part of that window on an old shell.
|
|
54
|
+
*/
|
|
55
|
+
const CAPTURE_TIMEOUT_MS = 30000
|
|
56
|
+
/** Where a grab lands before it is read back and unlinked. */
|
|
57
|
+
const CAPTURE_DIRNAME = 'dsh-read-image-preview'
|
|
58
|
+
/** The first request answers this many of the newest shots; the page loads more on demand. */
|
|
59
|
+
const LIST_PAGE = 60
|
|
60
|
+
|
|
61
|
+
/** Screenshot workspace settings. Every field is live without a restart. */
|
|
62
|
+
export const Config = z.object({
|
|
63
|
+
/** Directory holding saved shots. Empty means `<dsh home>/screenshots`. */
|
|
64
|
+
screenshotDir: z.string().default('').volatile(),
|
|
65
|
+
/** Drop a shot once it is older than this many days; 0 turns age pruning off. */
|
|
66
|
+
retentionDays: z.number().step(1).min(0).default(7).volatile(),
|
|
67
|
+
/** Keep at most this many shots, newest first. */
|
|
68
|
+
retentionMaxFiles: z.number().step(1).min(1).default(300).volatile(),
|
|
69
|
+
/** Keep at most this many megabytes of shots, newest first. */
|
|
70
|
+
retentionMaxMb: z.number().step(1).min(1).default(500).volatile(),
|
|
71
|
+
/** How often the retention policy runs by itself. */
|
|
72
|
+
cleanupIntervalMinutes: z.number().step(1).min(5).default(360).volatile(),
|
|
73
|
+
/** Delay a grab waits by default, so the target window can be raised first. */
|
|
74
|
+
captureDelaySeconds: z.number().step(1).min(0).max(60).default(0).volatile(),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/** Read one Config field whether it arrived as a live ref or as a plain value. */
|
|
78
|
+
function setting(value, fallback) {
|
|
79
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
|
|
80
|
+
const read = value.get()
|
|
81
|
+
return read === undefined || read === null ? fallback : read
|
|
82
|
+
}
|
|
83
|
+
return value === undefined || value === null ? fallback : value
|
|
84
|
+
}
|
|
34
85
|
|
|
35
86
|
const tokens = new Map()
|
|
36
87
|
let issued = 0
|
|
@@ -50,68 +101,12 @@ function sniff(bytes) {
|
|
|
50
101
|
}
|
|
51
102
|
|
|
52
103
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* The card sizes its loading placeholder to the real aspect ratio and prints the
|
|
56
|
-
* true size, both before the image itself has loaded, so this runs on the same
|
|
57
|
-
* header window as the sniff and never touches the whole file.
|
|
58
|
-
* @param {Uint8Array} bytes - the header window.
|
|
59
|
-
* @param {string} mediaType - the container already identified by {@link sniff}.
|
|
60
|
-
* @returns {{ width: number, height: number } | null} dimensions, or null when the header does not carry them.
|
|
61
|
-
*/
|
|
62
|
-
function dimensionsOf(bytes, mediaType) {
|
|
63
|
-
try {
|
|
64
|
-
if (bytes.length < 24) return null
|
|
65
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
66
|
-
if (mediaType === 'image/png') return { width: view.getUint32(16), height: view.getUint32(20) }
|
|
67
|
-
if (mediaType === 'image/gif') return { width: view.getUint16(6, true), height: view.getUint16(8, true) }
|
|
68
|
-
if (mediaType === 'image/webp') {
|
|
69
|
-
// Only the extended (VP8X) form states a canvas size in the header.
|
|
70
|
-
if (bytes.length >= 30 && bytes[15] === 86) {
|
|
71
|
-
return { width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 }
|
|
72
|
-
}
|
|
73
|
-
return null
|
|
74
|
-
}
|
|
75
|
-
if (mediaType === 'image/jpeg') {
|
|
76
|
-
let at = 2
|
|
77
|
-
while (at + 9 < bytes.length) {
|
|
78
|
-
if (bytes[at] !== 255) {
|
|
79
|
-
at += 1
|
|
80
|
-
continue
|
|
81
|
-
}
|
|
82
|
-
const marker = bytes[at + 1]
|
|
83
|
-
// Fill bytes and the standalone markers carry no length word.
|
|
84
|
-
if (marker === 255) {
|
|
85
|
-
at += 1
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
if (marker >= 208 && marker <= 215) {
|
|
89
|
-
at += 2
|
|
90
|
-
continue
|
|
91
|
-
}
|
|
92
|
-
if (marker === 217 || marker === 218) return null
|
|
93
|
-
const length = view.getUint16(at + 2)
|
|
94
|
-
if (length < 2) return null
|
|
95
|
-
// Any SOFn frame header states the size; the arithmetic-coded forms do not.
|
|
96
|
-
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
97
|
-
return { height: view.getUint16(at + 5), width: view.getUint16(at + 7) }
|
|
98
|
-
}
|
|
99
|
-
at += 2 + length
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
} catch {
|
|
103
|
-
return null
|
|
104
|
-
}
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Mint or reuse the capability token for one resolved image.
|
|
104
|
+
* Mint or reuse the capability token for one saved shot.
|
|
110
105
|
*
|
|
111
106
|
* The same path asked for twice yields one token, so a re-render cannot grow the
|
|
112
107
|
* registry; the oldest entry is evicted past the cap.
|
|
113
|
-
* @param {string} path - the
|
|
114
|
-
* @param {string} cwd - the
|
|
108
|
+
* @param {string} path - the shot's file name.
|
|
109
|
+
* @param {string} cwd - the screenshot directory it lives in.
|
|
115
110
|
* @param {string} mediaType - the sniffed container.
|
|
116
111
|
* @param {number} size - the file's byte length.
|
|
117
112
|
* @returns {string} the capability token.
|
|
@@ -179,54 +174,255 @@ function sendText(res, status, message) {
|
|
|
179
174
|
res.end(message)
|
|
180
175
|
}
|
|
181
176
|
|
|
177
|
+
/** The DSH home the screenshot directory hangs off, matching the host's own layout. */
|
|
178
|
+
function dshHome() {
|
|
179
|
+
const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
|
|
180
|
+
if (fromEnv !== '') return fromEnv
|
|
181
|
+
return path.join(os.homedir(), '.dsh')
|
|
182
|
+
}
|
|
183
|
+
|
|
182
184
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
+
* The directory saved shots live in.
|
|
186
|
+
*
|
|
187
|
+
* A configured path wins, `~` expands, and a relative path hangs off the DSH
|
|
188
|
+
* home so that a bare `my-shots` never depends on which directory the host was
|
|
189
|
+
* started from.
|
|
190
|
+
* @param {object} config - the live plugin config.
|
|
191
|
+
* @returns {string} an absolute directory.
|
|
185
192
|
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
function shotsDirOf(config) {
|
|
194
|
+
const raw = String(setting(config?.screenshotDir, '')).trim()
|
|
195
|
+
if (raw === '') return path.join(dshHome(), 'screenshots')
|
|
196
|
+
const expanded = raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')
|
|
197
|
+
? path.join(os.homedir(), raw.slice(1))
|
|
198
|
+
: raw
|
|
199
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(dshHome(), expanded)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The live retention policy, with the byte cap converted out of megabytes.
|
|
204
|
+
* @param {object} config - the live plugin config.
|
|
205
|
+
* @returns {{ days: number, maxFiles: number, maxBytes: number }} the policy.
|
|
206
|
+
*/
|
|
207
|
+
function policyOf(config) {
|
|
208
|
+
return {
|
|
209
|
+
days: Number(setting(config?.retentionDays, 7)),
|
|
210
|
+
maxFiles: Number(setting(config?.retentionMaxFiles, 300)),
|
|
211
|
+
maxBytes: Number(setting(config?.retentionMaxMb, 500)) * 1024 * 1024,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Decide which shots the retention policy drops.
|
|
217
|
+
*
|
|
218
|
+
* Age goes first — a shot past its deadline leaves regardless of how much room
|
|
219
|
+
* is left — then the newest-first count cap, then the newest-first byte cap.
|
|
220
|
+
* @param {Array<{ name: string, bytes: number, mtime: number }>} entries - every shot, any order.
|
|
221
|
+
* @param {{ days: number, maxFiles: number, maxBytes: number, now?: number }} policy - the caps to enforce.
|
|
222
|
+
* @returns {{ kept: Array<{ name: string, bytes: number }>, removed: string[] }} the survivors and the doomed names.
|
|
223
|
+
*/
|
|
224
|
+
export function planCleanup(entries, policy) {
|
|
225
|
+
const now = typeof policy.now === 'number' ? policy.now : Date.now()
|
|
226
|
+
// A zero-day policy has no deadline at all, so nothing fails on age alone.
|
|
227
|
+
const deadline = policy.days > 0 ? now - policy.days * 86400000 : 0
|
|
228
|
+
const byAge = entries.slice().sort((a, b) => b.mtime - a.mtime)
|
|
229
|
+
const kept = []
|
|
230
|
+
const removed = []
|
|
231
|
+
for (const entry of byAge) {
|
|
232
|
+
if (entry.mtime < deadline) removed.push(entry.name)
|
|
233
|
+
else kept.push({ name: entry.name, bytes: entry.bytes })
|
|
234
|
+
}
|
|
235
|
+
while (kept.length > policy.maxFiles) removed.push(kept.pop().name)
|
|
236
|
+
let total = kept.reduce((sum, entry) => sum + entry.bytes, 0)
|
|
237
|
+
while (kept.length > 0 && total > policy.maxBytes) {
|
|
238
|
+
total -= kept.pop().bytes
|
|
239
|
+
}
|
|
240
|
+
removed.sort()
|
|
241
|
+
return { kept, removed }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A shot name only ever produced by this plugin: `shot-<stamp>-<random>.png`. */
|
|
245
|
+
function shotName(now) {
|
|
246
|
+
const stamp = new Date(now)
|
|
247
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
|
248
|
+
const at = stamp.getFullYear() + pad(stamp.getMonth() + 1) + pad(stamp.getDate())
|
|
249
|
+
+ '-' + pad(stamp.getHours()) + pad(stamp.getMinutes()) + pad(stamp.getSeconds()) + pad(stamp.getMilliseconds(), 3)
|
|
250
|
+
return 'shot-' + at + '-' + Math.floor(Math.random() * 1296).toString(36).padStart(2, '0') + '.png'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether a client-supplied string is one of this plugin's own shot file names. */
|
|
254
|
+
function isShotName(name) {
|
|
255
|
+
return /^shot-[0-9]{8}-[0-9]{9}-[0-9a-z]{2}\.png$/.test(name)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read a request body with a hard ceiling.
|
|
260
|
+
* @param {import('node:http').IncomingMessage} req - the request to drain.
|
|
261
|
+
* @param {number} limit - the largest accepted body.
|
|
262
|
+
* @returns {Promise<Buffer>} the body.
|
|
263
|
+
*/
|
|
264
|
+
function readBody(req, limit) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const chunks = []
|
|
267
|
+
let size = 0
|
|
268
|
+
req.on('data', (chunk) => {
|
|
269
|
+
size += chunk.length
|
|
270
|
+
if (size > limit) {
|
|
271
|
+
reject(new Error('请求体超过 ' + limit + ' 字节'))
|
|
272
|
+
req.destroy()
|
|
194
273
|
return
|
|
195
274
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
275
|
+
chunks.push(chunk)
|
|
276
|
+
})
|
|
277
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
278
|
+
req.on('error', reject)
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The PowerShell a Windows grab runs, with `target` spliced in as a literal.
|
|
284
|
+
*
|
|
285
|
+
* The process is made per-monitor DPI aware before any screen metric is read: a
|
|
286
|
+
* plain capture under a scaling factor reports a virtualized, too-small desktop
|
|
287
|
+
* and the bitmap comes back resampled (1600×1000 for a 3200×2000 screen at 200%).
|
|
288
|
+
* `Application.SetHighDpiMode` does that with managed calls in about half a
|
|
289
|
+
* second; the P/Invoke form behind it is kept for a shell whose framework is too
|
|
290
|
+
* old to carry the type, and costs about twenty seconds of C# compilation there.
|
|
291
|
+
* @param {string} target - the absolute file to write.
|
|
292
|
+
* @returns {string} the script text.
|
|
293
|
+
*/
|
|
294
|
+
export function windowsCaptureScript(target) {
|
|
295
|
+
const literal = target.replace(/'/g, "''")
|
|
296
|
+
return [
|
|
297
|
+
"$ErrorActionPreference = 'Stop'",
|
|
298
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
299
|
+
'Add-Type -AssemblyName System.Drawing',
|
|
300
|
+
'try {',
|
|
301
|
+
' [void][System.Windows.Forms.Application]::SetHighDpiMode([System.Windows.Forms.HighDpiMode]::PerMonitorV2)',
|
|
302
|
+
'} catch {',
|
|
303
|
+
' Add-Type \'using System; using System.Runtime.InteropServices; '
|
|
304
|
+
+ 'public static class DshDpi { '
|
|
305
|
+
+ '[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); '
|
|
306
|
+
+ '[DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'',
|
|
307
|
+
' [void][DshDpi]::SetProcessDpiAwarenessContext([IntPtr](-4))',
|
|
308
|
+
' [void][DshDpi]::SetProcessDPIAware()',
|
|
309
|
+
'}',
|
|
310
|
+
'$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen',
|
|
311
|
+
'$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height',
|
|
312
|
+
'try {',
|
|
313
|
+
' $graphics = [System.Drawing.Graphics]::FromImage($bitmap)',
|
|
314
|
+
' try { $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) }',
|
|
315
|
+
' finally { $graphics.Dispose() }',
|
|
316
|
+
" $bitmap.Save('" + literal + "', [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
317
|
+
'} finally { $bitmap.Dispose() }',
|
|
318
|
+
].join('\n')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The grab commands to try, in order, for one platform.
|
|
323
|
+
*
|
|
324
|
+
* Windows goes through PowerShell because it is the one capture path present on
|
|
325
|
+
* every install and the only one that can raise DPI awareness first; the other
|
|
326
|
+
* platforms name the grabbers that exist there, tried until one produces a file.
|
|
327
|
+
* @param {string} platform - `process.platform`.
|
|
328
|
+
* @param {string} target - the absolute file the grabber must write.
|
|
329
|
+
* @returns {Array<{ file: string, args: string[], note: string }>} candidate commands.
|
|
330
|
+
*/
|
|
331
|
+
export function captureCommands(platform, target) {
|
|
332
|
+
if (platform === 'win32') {
|
|
333
|
+
const args = (script) => ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
334
|
+
'-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]
|
|
335
|
+
const script = windowsCaptureScript(target)
|
|
336
|
+
const root = typeof process.env.SystemRoot === 'string' && process.env.SystemRoot !== ''
|
|
337
|
+
? process.env.SystemRoot
|
|
338
|
+
: 'C:\\Windows'
|
|
339
|
+
return [
|
|
340
|
+
{ file: 'pwsh', args: args(script), note: 'PowerShell 7' },
|
|
341
|
+
{ file: path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), args: args(script), note: 'Windows PowerShell' },
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
if (platform === 'darwin') {
|
|
345
|
+
return [{ file: '/usr/sbin/screencapture', args: ['-x', '-t', 'png', target], note: 'screencapture(主显示器)' }]
|
|
346
|
+
}
|
|
347
|
+
return [
|
|
348
|
+
{ file: 'grim', args: [target], note: 'grim (Wayland)' },
|
|
349
|
+
{ file: 'gnome-screenshot', args: ['-f', target], note: 'gnome-screenshot' },
|
|
350
|
+
{ file: 'spectacle', args: ['-b', '-n', '-o', target], note: 'spectacle' },
|
|
351
|
+
{ file: 'scrot', args: ['--overwrite', target], note: 'scrot' },
|
|
352
|
+
{ file: 'import', args: ['-window', 'root', 'png:' + target], note: 'ImageMagick import (X11)' },
|
|
353
|
+
]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Run one command to completion.
|
|
358
|
+
* @param {string} file - the executable.
|
|
359
|
+
* @param {string[]} args - its arguments.
|
|
360
|
+
* @param {number} timeoutMs - the deadline after which the child is killed.
|
|
361
|
+
* @returns {Promise<{ ok: boolean, message: string }>} success, or the reason it failed.
|
|
362
|
+
*/
|
|
363
|
+
function runCommand(file, args, timeoutMs) {
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
|
366
|
+
(error, stdout, stderr) => {
|
|
367
|
+
if (error === null || error === undefined) {
|
|
368
|
+
resolve({ ok: true, message: '' })
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
const detail = String(stderr || stdout || error.message || '').trim().split('\n').slice(-3).join(' / ')
|
|
372
|
+
resolve({ ok: false, message: (error.code === 'ENOENT' ? '没有这个命令: ' + file : detail || file + ' 执行失败') })
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Grab the desktop with the first command that works.
|
|
379
|
+
* @param {string} platform - `process.platform`.
|
|
380
|
+
* @param {{ timeoutMs?: number, log?: (line: string) => void }} options - overrides for the grab.
|
|
381
|
+
* @returns {Promise<{ bytes: Buffer, mediaType: string }>} the captured PNG.
|
|
382
|
+
*/
|
|
383
|
+
export async function grabDesktop(platform, options = {}) {
|
|
384
|
+
const dir = path.join(os.tmpdir(), CAPTURE_DIRNAME)
|
|
385
|
+
await fs.mkdir(dir, { recursive: true })
|
|
386
|
+
const target = path.join(dir, 'grab-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.png')
|
|
387
|
+
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : CAPTURE_TIMEOUT_MS
|
|
388
|
+
const reasons = []
|
|
389
|
+
try {
|
|
390
|
+
for (const command of captureCommands(platform, target)) {
|
|
391
|
+
const ran = await runCommand(command.file, command.args, timeoutMs)
|
|
392
|
+
if (ran.ok !== true) {
|
|
393
|
+
reasons.push(command.note + ': ' + ran.message)
|
|
394
|
+
continue
|
|
202
395
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
396
|
+
let info
|
|
397
|
+
try {
|
|
398
|
+
info = await fs.stat(target)
|
|
399
|
+
} catch {
|
|
400
|
+
reasons.push(command.note + ': 没有写出截图文件')
|
|
401
|
+
continue
|
|
206
402
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
sendJson(res, 200, { ok: false, error: 'not a PNG/JPEG/WebP/GIF image' })
|
|
211
|
-
return
|
|
403
|
+
if (info.size <= 0) {
|
|
404
|
+
reasons.push(command.note + ': 截图文件是空的')
|
|
405
|
+
continue
|
|
212
406
|
}
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
url: ROUTE + '/' + issue(raw, cwd, mediaType, size),
|
|
220
|
-
width: dimensions === null ? null : dimensions.width,
|
|
221
|
-
height: dimensions === null ? null : dimensions.height,
|
|
222
|
-
displayPath: typeof target.displayPath === 'string' ? target.displayPath : raw,
|
|
223
|
-
})
|
|
224
|
-
} catch (error) {
|
|
225
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
226
|
-
sendJson(res, 200, { ok: false, error: message })
|
|
407
|
+
const bytes = await fs.readFile(target)
|
|
408
|
+
if (sniff(bytes) !== 'image/png') {
|
|
409
|
+
reasons.push(command.note + ': 输出的不是 PNG')
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
return { bytes, mediaType: 'image/png' }
|
|
227
413
|
}
|
|
414
|
+
} finally {
|
|
415
|
+
fs.unlink(target).catch(() => {})
|
|
228
416
|
}
|
|
417
|
+
throw new Error('抓屏失败(' + platform + '):' + reasons.join(';'))
|
|
418
|
+
}
|
|
229
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Host plugin: publish the shot image route and the screenshot workspace routes.
|
|
422
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
|
|
423
|
+
* @param {object} [config] - the live plugin config.
|
|
424
|
+
*/
|
|
425
|
+
export function apply(ctx, config) {
|
|
230
426
|
/** Serve the bytes one issued token stands for. */
|
|
231
427
|
const serve = async (req, res) => {
|
|
232
428
|
try {
|
|
@@ -268,12 +464,281 @@ export function apply(ctx) {
|
|
|
268
464
|
}
|
|
269
465
|
}
|
|
270
466
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
467
|
+
/**
|
|
468
|
+
* The trust check every screenshot route runs first.
|
|
469
|
+
*
|
|
470
|
+
* These routes write files and start a screen grab, so they are gated by the
|
|
471
|
+
* composition's own Host/Origin and authentication fence rather than by an
|
|
472
|
+
* opaque token; a deployment without that service gets a refusal instead of
|
|
473
|
+
* an unguarded writer. The read-only shot image route keeps its existing
|
|
474
|
+
* token-only authorization.
|
|
475
|
+
* @param {import('node:http').IncomingMessage} req - the request to test.
|
|
476
|
+
* @param {import('node:http').ServerResponse} res - the response to own on refusal.
|
|
477
|
+
* @returns {boolean} true when the request was answered and must go no further.
|
|
478
|
+
*/
|
|
479
|
+
const refuse = (req, res) => {
|
|
480
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
481
|
+
if (connection === undefined || typeof connection.requestRejection !== 'function') {
|
|
482
|
+
sendJson(res, 503, { ok: false, error: '当前部署没有 connection 服务,截图保存/抓屏已停用' })
|
|
483
|
+
return true
|
|
484
|
+
}
|
|
485
|
+
const rejection = connection.requestRejection(req)
|
|
486
|
+
if (rejection === undefined) return false
|
|
487
|
+
sendJson(res, rejection, { ok: false, error: rejection === 401 ? 'unauthorized' : 'forbidden' })
|
|
488
|
+
return true
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Answer 405 unless the request carries the one method this route owns. */
|
|
492
|
+
const wrongMethod = (req, res, method) => {
|
|
493
|
+
if (req.method === method) return false
|
|
494
|
+
res.writeHead(405, { allow: method, 'content-type': 'text/plain; charset=utf-8' })
|
|
495
|
+
res.end(method + ' only')
|
|
496
|
+
return true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The media type one saved shot serves as, or null when it is not a served container. */
|
|
500
|
+
const shotMediaType = (name) => {
|
|
501
|
+
const at = name.lastIndexOf('.')
|
|
502
|
+
const extension = at === -1 ? '' : name.slice(at + 1).toLowerCase()
|
|
503
|
+
if (extension === 'png') return 'image/png'
|
|
504
|
+
if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg'
|
|
505
|
+
if (extension === 'gif') return 'image/gif'
|
|
506
|
+
if (extension === 'webp') return 'image/webp'
|
|
507
|
+
return null
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Every shot file in the directory, newest first.
|
|
512
|
+
*
|
|
513
|
+
* A file that disappears between the listing and its stat is simply absent
|
|
514
|
+
* from the answer; nothing else an entry says is trusted beyond its own stat.
|
|
515
|
+
*/
|
|
516
|
+
const listShots = async (dir) => {
|
|
517
|
+
let names = []
|
|
518
|
+
try {
|
|
519
|
+
names = await fs.readdir(dir)
|
|
520
|
+
} catch {
|
|
521
|
+
return []
|
|
522
|
+
}
|
|
523
|
+
const entries = []
|
|
524
|
+
for (const name of names) {
|
|
525
|
+
if (name.startsWith('.') || shotMediaType(name) === null) continue
|
|
526
|
+
try {
|
|
527
|
+
const info = await fs.stat(path.join(dir, name))
|
|
528
|
+
if (info.isFile() === true) entries.push({ name, bytes: info.size, mtime: info.mtimeMs })
|
|
529
|
+
} catch {
|
|
530
|
+
/* it went away mid-listing */
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return entries.sort((a, b) => b.mtime - a.mtime)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Drop what the retention policy rejects, and report what that cost. */
|
|
537
|
+
const runCleanup = async (reason) => {
|
|
538
|
+
const dir = shotsDirOf(config)
|
|
539
|
+
const policy = policyOf(config)
|
|
540
|
+
const entries = await listShots(dir)
|
|
541
|
+
const plan = planCleanup(entries.filter((entry) => isShotName(entry.name)), { ...policy, now: Date.now() })
|
|
542
|
+
let freed = 0
|
|
543
|
+
let failed = 0
|
|
544
|
+
for (const name of plan.removed) {
|
|
545
|
+
try {
|
|
546
|
+
await fs.unlink(path.join(dir, name))
|
|
547
|
+
freed += entries.find((entry) => entry.name === name)?.bytes ?? 0
|
|
548
|
+
} catch {
|
|
549
|
+
failed += 1
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (plan.removed.length > 0 || failed > 0) {
|
|
553
|
+
ctx.logger.info('read-image-preview: 清理截图(' + reason + ')删除 ' + (plan.removed.length - failed)
|
|
554
|
+
+ ' 个 / ' + formatBytes(freed) + ',保留 ' + plan.kept.length + ' 个')
|
|
555
|
+
}
|
|
556
|
+
return { removed: plan.removed.length - failed, failed, kept: plan.kept.length, freed }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Everything the 截图 page shows besides the list itself. */
|
|
560
|
+
const state = (req, res) => {
|
|
561
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'GET') === true) return
|
|
562
|
+
const dir = shotsDirOf(config)
|
|
563
|
+
const policy = policyOf(config)
|
|
564
|
+
sendJson(res, 200, {
|
|
565
|
+
ok: true,
|
|
566
|
+
dir,
|
|
567
|
+
platform: process.platform,
|
|
568
|
+
policy,
|
|
569
|
+
delaySeconds: Number(setting(config?.captureDelaySeconds, 0)),
|
|
570
|
+
// Names this platform will try, shown when a grab has to explain itself.
|
|
571
|
+
capture: captureCommands(process.platform, 'x.png').map((command) => command.note),
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The shot listing, newest first, with a capability URL per thumbnail. */
|
|
576
|
+
const shots = async (req, res) => {
|
|
577
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'GET') === true) return
|
|
578
|
+
try {
|
|
579
|
+
const url = typeof req.url === 'string' ? req.url : ''
|
|
580
|
+
const limit = Math.max(1, Math.min(300, Number(queryOf(url, 'limit')) || LIST_PAGE))
|
|
581
|
+
const offset = Math.max(0, Number(queryOf(url, 'offset')) || 0)
|
|
582
|
+
const dir = shotsDirOf(config)
|
|
583
|
+
const entries = await listShots(dir)
|
|
584
|
+
const page = entries.slice(offset, offset + limit).map((entry) => ({
|
|
585
|
+
name: entry.name,
|
|
586
|
+
bytes: entry.bytes,
|
|
587
|
+
mtime: entry.mtime,
|
|
588
|
+
url: ROUTE + '/' + issue(entry.name, dir, shotMediaType(entry.name), entry.bytes),
|
|
589
|
+
}))
|
|
590
|
+
sendJson(res, 200, {
|
|
591
|
+
ok: true, dir, total: entries.length, offset,
|
|
592
|
+
bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
593
|
+
items: page,
|
|
594
|
+
})
|
|
595
|
+
} catch (error) {
|
|
596
|
+
sendJson(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Write one annotated PNG into the screenshot directory. */
|
|
601
|
+
const save = async (req, res) => {
|
|
602
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'POST') === true) return
|
|
603
|
+
try {
|
|
604
|
+
const body = await readBody(req, MAX_SAVE_BYTES)
|
|
605
|
+
const mediaType = sniff(body)
|
|
606
|
+
if (mediaType !== 'image/png') {
|
|
607
|
+
sendJson(res, 400, { ok: false, error: '只接受 PNG 图片内容' })
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
const dir = shotsDirOf(config)
|
|
611
|
+
await fs.mkdir(dir, { recursive: true })
|
|
612
|
+
const name = shotName(Date.now())
|
|
613
|
+
const target = path.join(dir, name)
|
|
614
|
+
await fs.writeFile(target, body, { flag: 'wx' })
|
|
615
|
+
sendJson(res, 200, {
|
|
616
|
+
ok: true, name, path: target, dir, bytes: body.length,
|
|
617
|
+
url: ROUTE + '/' + issue(name, dir, mediaType, body.length),
|
|
618
|
+
})
|
|
619
|
+
} catch (error) {
|
|
620
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Remove one named shot. Only a plain file name inside the shot directory reaches disk. */
|
|
625
|
+
const remove = async (req, res) => {
|
|
626
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'POST') === true) return
|
|
627
|
+
try {
|
|
628
|
+
const url = typeof req.url === 'string' ? req.url : ''
|
|
629
|
+
const name = queryOf(url, 'name')
|
|
630
|
+
if (name === '' || /[\\/:*?"<>|]/.test(name) || name.startsWith('.')) {
|
|
631
|
+
sendJson(res, 400, { ok: false, error: '不是合法的截图文件名' })
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
await fs.unlink(path.join(shotsDirOf(config), name))
|
|
635
|
+
sendJson(res, 200, { ok: true, name })
|
|
636
|
+
} catch (error) {
|
|
637
|
+
const missing = error instanceof Error && error.code === 'ENOENT'
|
|
638
|
+
sendJson(res, missing ? 404 : 500, {
|
|
639
|
+
ok: false, error: missing ? '截图已经不在了' : (error instanceof Error ? error.message : String(error)),
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Run the retention policy now, on request. */
|
|
645
|
+
const cleanup = async (req, res) => {
|
|
646
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'POST') === true) return
|
|
647
|
+
try {
|
|
648
|
+
sendJson(res, 200, { ok: true, ...(await runCleanup('手动')) })
|
|
649
|
+
} catch (error) {
|
|
650
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Grab the desktop and answer with the PNG, after the requested settle delay. */
|
|
655
|
+
const capture = async (req, res) => {
|
|
656
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'POST') === true) return
|
|
657
|
+
try {
|
|
658
|
+
const url = typeof req.url === 'string' ? req.url : ''
|
|
659
|
+
const asked = Number(queryOf(url, 'delay'))
|
|
660
|
+
const delay = Number.isFinite(asked) ? Math.max(0, Math.min(60000, asked))
|
|
661
|
+
: Math.max(0, Math.min(60, Number(setting(config?.captureDelaySeconds, 0)))) * 1000
|
|
662
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
|
|
663
|
+
const started = Date.now()
|
|
664
|
+
const { bytes, mediaType } = await grabDesktop(process.platform)
|
|
665
|
+
res.writeHead(200, {
|
|
666
|
+
'content-type': mediaType,
|
|
667
|
+
'content-length': String(bytes.length),
|
|
668
|
+
'cache-control': 'no-store',
|
|
669
|
+
'x-content-type-options': 'nosniff',
|
|
670
|
+
})
|
|
671
|
+
res.end(bytes)
|
|
672
|
+
ctx.logger.info('read-image-preview: 抓屏 ' + bytes.length + ' 字节,用时 ' + (Date.now() - started) + 'ms')
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
675
|
+
ctx.logger.warn('read-image-preview: ' + message.slice(0, 300))
|
|
676
|
+
sendJson(res, 500, { ok: false, error: message })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Reveal the screenshot directory in the platform's file manager. */
|
|
681
|
+
const open = (req, res) => {
|
|
682
|
+
if (refuse(req, res) === true || wrongMethod(req, res, 'POST') === true) return
|
|
683
|
+
const dir = shotsDirOf(config)
|
|
684
|
+
fs.mkdir(dir, { recursive: true }).then(() => {
|
|
685
|
+
const launcher = process.platform === 'win32'
|
|
686
|
+
? { file: 'explorer.exe', args: [dir] }
|
|
687
|
+
: process.platform === 'darwin'
|
|
688
|
+
? { file: 'open', args: [dir] }
|
|
689
|
+
: { file: 'xdg-open', args: [dir] }
|
|
690
|
+
const child = spawn(launcher.file, launcher.args, { detached: true, stdio: 'ignore' })
|
|
691
|
+
child.unref()
|
|
692
|
+
sendJson(res, 200, { ok: true, dir })
|
|
693
|
+
}, (error) => {
|
|
694
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
275
698
|
ctx.effect(
|
|
276
699
|
() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler: serve }),
|
|
277
700
|
'read-image-preview: image route',
|
|
278
701
|
)
|
|
702
|
+
for (const [name, handler] of [
|
|
703
|
+
['state', state], ['list', shots], ['save', save],
|
|
704
|
+
['delete', remove], ['cleanup', cleanup], ['capture', capture], ['open', open],
|
|
705
|
+
]) {
|
|
706
|
+
ctx.effect(
|
|
707
|
+
() => ctx.webServer.register({ kind: 'exact', path: ROUTE + '/' + name, handler }),
|
|
708
|
+
'read-image-preview: ' + name + ' endpoint',
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Retention runs by itself: once shortly after activation, then on a heartbeat
|
|
713
|
+
// that re-reads its period, so changing cleanupIntervalMinutes needs no restart.
|
|
714
|
+
const heartbeatMs = 5 * 60 * 1000
|
|
715
|
+
let nextRun = Date.now() + 8000
|
|
716
|
+
const first = setTimeout(() => {
|
|
717
|
+
runCleanup('启动').catch((error) => {
|
|
718
|
+
ctx.logger.warn('read-image-preview: 启动清理失败 ' + (error instanceof Error ? error.message : String(error)))
|
|
719
|
+
})
|
|
720
|
+
}, 8000)
|
|
721
|
+
const heartbeat = setInterval(() => {
|
|
722
|
+
const now = Date.now()
|
|
723
|
+
if (now < nextRun) return
|
|
724
|
+
const minutes = Math.max(5, Number(setting(config?.cleanupIntervalMinutes, 360)))
|
|
725
|
+
nextRun = now + minutes * 60 * 1000
|
|
726
|
+
runCleanup('定时').catch(() => { /* the next heartbeat tries again */ })
|
|
727
|
+
}, heartbeatMs)
|
|
728
|
+
// Neither timer may be the last thing holding the host open: retention is a
|
|
729
|
+
// housekeeping job, and a host that wants to exit is not waiting on it.
|
|
730
|
+
first.unref()
|
|
731
|
+
heartbeat.unref()
|
|
732
|
+
ctx.effect(() => () => {
|
|
733
|
+
clearTimeout(first)
|
|
734
|
+
clearInterval(heartbeat)
|
|
735
|
+
}, 'read-image-preview: retention timers')
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Render a byte count at reading size, for the log line the cleanup writes. */
|
|
739
|
+
function formatBytes(bytes) {
|
|
740
|
+
if (!(bytes > 0)) return '0 B'
|
|
741
|
+
if (bytes < 1024) return bytes + ' B'
|
|
742
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
743
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
279
744
|
}
|