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.
Files changed (5) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +129 -33
  3. package/client.js +2594 -718
  4. package/index.js +584 -119
  5. 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
- * `read_image` hands the model a normalized copy of a local file, and a browser
5
- * slot view has no filesystem privileges of its own, so the bytes have to come
6
- * back over the page's own transport. This half reads the file through the
7
- * composed `fs` service, validates the container by magic bytes, and publishes
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/resolve?path=…&cwd=…` — resolve one tool-supplied
11
- * path and answer with a JSON-safe description plus a capability URL;
12
- * - `GET /read-image-preview/<token>` — the bytes themselves, for `<img src>`.
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
- * Only the four bitmap containers are served, and the sniffed media type — not
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 = 64
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
- * Read the pixel dimensions out of the container header alone.
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 path as the tool received it.
114
- * @param {string} cwd - the session workspace root used to resolve it.
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
- * Host plugin: publish the resolve endpoint and the image route.
184
- * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
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
- export function apply(ctx) {
187
- /** Resolve one query-supplied path into a JSON-safe description. */
188
- const resolve = async (req, res) => {
189
- try {
190
- const url = typeof req.url === 'string' ? req.url : ''
191
- const raw = queryOf(url, 'path')
192
- if (raw.trim() === '') {
193
- sendJson(res, 400, { ok: false, error: 'no path supplied' })
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
- const cwd = queryOf(url, 'cwd')
197
- const target = await ctx.fs.resolve(raw, cwd === '' ? undefined : { cwd })
198
- const info = await ctx.fs.stat(target)
199
- if (info === undefined) {
200
- sendJson(res, 200, { ok: false, error: 'file not found: ' + raw })
201
- return
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
- if (info.type !== 'file') {
204
- sendJson(res, 200, { ok: false, error: 'not a regular file: ' + raw })
205
- return
396
+ let info
397
+ try {
398
+ info = await fs.stat(target)
399
+ } catch {
400
+ reasons.push(command.note + ': 没有写出截图文件')
401
+ continue
206
402
  }
207
- const head = await ctx.fs.readByteRange(target, { offset: 0, length: HEAD_BYTES })
208
- const mediaType = sniff(head)
209
- if (mediaType === null) {
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 size = typeof info.size === 'number' ? info.size : 0
214
- const dimensions = dimensionsOf(head, mediaType)
215
- sendJson(res, 200, {
216
- ok: true,
217
- mediaType,
218
- bytes: size,
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
- ctx.effect(
272
- () => ctx.webServer.register({ kind: 'exact', path: ROUTE + '/resolve', handler: resolve }),
273
- 'read-image-preview: resolve endpoint',
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
  }