@yfwu2020/dsh-selection-explain 0.11.1 → 0.13.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 (44) hide show
  1. package/README.md +25 -5
  2. package/lib/chat-context.js +86 -0
  3. package/lib/chat-context.js.map +1 -0
  4. package/lib/client.js +1978 -168
  5. package/lib/client.js.map +4 -4
  6. package/lib/config-values.js +6 -0
  7. package/lib/config-values.js.map +1 -1
  8. package/lib/host/analyze.js +199 -47
  9. package/lib/host/analyze.js.map +1 -1
  10. package/lib/host/chat-context.js +160 -0
  11. package/lib/host/chat-context.js.map +1 -0
  12. package/lib/host/chat-materials.js +64 -0
  13. package/lib/host/chat-materials.js.map +1 -0
  14. package/lib/host/config.js +8 -1
  15. package/lib/host/config.js.map +1 -1
  16. package/lib/host/history-attachments.js +17 -53
  17. package/lib/host/history-attachments.js.map +1 -1
  18. package/lib/host/history-store.js +93 -68
  19. package/lib/host/history-store.js.map +1 -1
  20. package/lib/host/http.js +2 -1
  21. package/lib/host/http.js.map +1 -1
  22. package/lib/host/plugin.js +31 -0
  23. package/lib/host/plugin.js.map +1 -1
  24. package/lib/host/promote.js +0 -1
  25. package/lib/host/promote.js.map +1 -1
  26. package/lib/host/settings-store.js +17 -18
  27. package/lib/host/settings-store.js.map +1 -1
  28. package/lib/host/shot.js +968 -0
  29. package/lib/host/shot.js.map +1 -0
  30. package/lib/prompts.js +54 -1
  31. package/lib/prompts.js.map +1 -1
  32. package/lib/settings.js +14 -1
  33. package/lib/settings.js.map +1 -1
  34. package/lib/types/chat-context.d.ts +53 -0
  35. package/lib/types/config-values.d.ts +30 -0
  36. package/lib/types/host/chat-context.d.ts +31 -0
  37. package/lib/types/host/chat-materials.d.ts +16 -0
  38. package/lib/types/host/config.d.ts +16 -2
  39. package/lib/types/host/history-attachments.d.ts +7 -40
  40. package/lib/types/host/history-store.d.ts +1 -0
  41. package/lib/types/host/shot.d.ts +73 -0
  42. package/lib/types/host/types.d.ts +17 -0
  43. package/lib/types/prompts.d.ts +21 -0
  44. package/package.json +4 -2
@@ -0,0 +1,968 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { readFile, mkdir, rm, stat, chmod, readdir } from 'node:fs/promises';
3
+ import { tmpdir, platform } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { sendJson, readBody } from './http.js';
7
+ /** 截图二进制(系统自带)。 */
8
+ const CAPTURE = '/usr/sbin/screencapture';
9
+ /** 交互式框选 + 整窗截图的总超时:用户可能慢慢框,给足 90 秒。 */
10
+ const SELECT_TIMEOUT_MS = 90_000;
11
+ /**
12
+ * 让"等待外部事件"的定时器不参与事件循环存活判定。
13
+ * 宿主进程退出/更新时,这类等待(长轮询、外部命令超时)没有继续存在的意义,
14
+ * 但一个普通的 setTimeout 会把进程钉住,直到它自己到期。
15
+ */
16
+ function unrefTimer(timer) {
17
+ ;
18
+ timer?.unref?.();
19
+ }
20
+ /** 窗口列表探针(随包编译,见 native/window-list.c)。 */
21
+ export function resolveWindowProbe() {
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ // lib/host/shot.js → lib/native/window-list
24
+ return join(here, '..', 'native', 'window-list');
25
+ }
26
+ const SHOT_SCOPES = ['screen', 'window', 'margin', 'selection'];
27
+ /** 超出这个尺寸的选区留白没有意义,且会把截图撑得很大。 */
28
+ const MAX_MARGIN_PT = 400;
29
+ /** 只保留最近几张临时图:客户端取图后就不需要旧文件了,但留几张给重试 / 诊断。 */
30
+ const KEEP_SHOTS = 3;
31
+ const state = {
32
+ available: false,
33
+ reason: 'idle',
34
+ capturing: false,
35
+ pending: null,
36
+ pendingError: null,
37
+ lastShot: null,
38
+ lastError: '',
39
+ total: 0,
40
+ };
41
+ /** 长轮询的等待者(成功或失败都会叫醒)。 */
42
+ const waiters = [];
43
+ /** 每次截图递增;临时文件名与事件 id 都用它,避免第二张图覆盖第一张。 */
44
+ let seq = 0;
45
+ /** 窗口列表探针是否可用(懒探测一次)。截图范围不依赖它时不会被它拦住。 */
46
+ let probeChecked = false;
47
+ let probeReady = false;
48
+ /** 框选期间预热的窗口列表 / 显示器列表:松手时就不用再等这几十毫秒。 */
49
+ let prefetchedWindows = null;
50
+ let prefetchedDisplays = null;
51
+ function tmpDir() {
52
+ return join(tmpdir(), 'dsh-selection-explain');
53
+ }
54
+ /** 临时目录只给当前用户读写:截图里可能有敏感内容。 */
55
+ async function ensureTmpDir() {
56
+ const dir = tmpDir();
57
+ await mkdir(dir, { recursive: true, mode: 0o700 });
58
+ await chmod(dir, 0o700).catch(() => { });
59
+ return dir;
60
+ }
61
+ function run(cmd, args, timeout = 10_000) {
62
+ return new Promise((resolve) => {
63
+ execFile(cmd, args, { timeout, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
64
+ resolve(error ? '' : String(stdout || ''));
65
+ });
66
+ });
67
+ }
68
+ /** 平台是否受支持(目前只做 macOS:整窗截图与矩形都依赖它)。 */
69
+ export function shotSupported() {
70
+ return platform() === 'darwin';
71
+ }
72
+ /** 平台可用性(不影响窗口探针的结论)。 */
73
+ function ensureCaptureAvailable() {
74
+ const ok = shotSupported();
75
+ state.available = ok;
76
+ state.reason = ok ? 'ok' : 'not-macos';
77
+ return ok;
78
+ }
79
+ /** 探针在不在(编不出来就只在「整窗」这一档降级,不影响选区/留白/全屏)。 */
80
+ async function probeAvailable() {
81
+ if (probeChecked)
82
+ return probeReady;
83
+ probeChecked = true;
84
+ if (!shotSupported()) {
85
+ probeReady = false;
86
+ return false;
87
+ }
88
+ const probe = resolveWindowProbe();
89
+ probeReady = await stat(probe).then((s) => s.size > 0).catch(() => false);
90
+ return probeReady;
91
+ }
92
+ async function listWindows() {
93
+ const raw = await run(resolveWindowProbe(), []);
94
+ if (!raw.trim())
95
+ return [];
96
+ try {
97
+ const arr = JSON.parse(raw);
98
+ return Array.isArray(arr) ? arr : [];
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ }
104
+ /** 活跃显示器的全局矩形(点)与像素尺寸。 */
105
+ async function listDisplays() {
106
+ const raw = await run(resolveWindowProbe(), ['--displays']);
107
+ if (!raw.trim())
108
+ return [];
109
+ try {
110
+ const arr = JSON.parse(raw);
111
+ return Array.isArray(arr)
112
+ ? arr
113
+ .filter((item) => Number.isFinite(item?.x) && Number.isFinite(item?.y) && Number.isFinite(item?.w) && Number.isFinite(item?.h))
114
+ .map((item) => ({
115
+ id: Number(item.id) || 0,
116
+ x: Number(item.x),
117
+ y: Number(item.y),
118
+ w: Number(item.w),
119
+ h: Number(item.h),
120
+ index: Number(item.index) || 0,
121
+ main: item.main === true || Number(item.main) === 1,
122
+ }))
123
+ : [];
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ function intersectArea(a, b) {
130
+ const left = Math.max(a.x, b.x);
131
+ const top = Math.max(a.y, b.y);
132
+ const right = Math.min(a.x + a.width, b.x + b.w);
133
+ const bottom = Math.min(a.y + a.height, b.y + b.h);
134
+ return Math.max(0, right - left) * Math.max(0, bottom - top);
135
+ }
136
+ /**
137
+ * 选区中心落在哪块屏。
138
+ *
139
+ * 用中心而不是"交集最大":选区跨屏时,按中心裁到一块屏才能保证**统一缩放**
140
+ * (混合 DPI 下 `screencapture -R` 跨屏的像素比例不是线性的)。
141
+ */
142
+ function pickDisplay(displays, rect) {
143
+ if (displays.length === 0)
144
+ return null;
145
+ const cx = rect.x + rect.width / 2;
146
+ const cy = rect.y + rect.height / 2;
147
+ const hits = displays.filter((d) => cx >= d.x && cx <= d.x + d.w && cy >= d.y && cy <= d.y + d.h);
148
+ if (hits.length > 0)
149
+ return hits[0];
150
+ const sorted = displays.slice().sort((a, b) => intersectArea(rect, b) - intersectArea(rect, a));
151
+ return sorted[0] || displays.find((d) => d.main) || null;
152
+ }
153
+ function clampRect(rect, box) {
154
+ const left = Math.max(rect.x, box.x);
155
+ const top = Math.max(rect.y, box.y);
156
+ const right = Math.min(rect.x + rect.width, box.x + box.w);
157
+ const bottom = Math.min(rect.y + rect.height, box.y + box.h);
158
+ return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
159
+ }
160
+ /**
161
+ * 读截图文件自带的矩形(全局点),`kMDItemScreenCaptureGlobalRect`。
162
+ *
163
+ * ⚠️ 这是 **binary plist**,不能"按顺序读 4 个 double":
164
+ * plist 会**去重相同的值** —— 比如一次 300×200 的框选,x=300、w=300、y=200、h=200,
165
+ * 库里只存两个 double,数组的四个引用指向它们;按顺序读就会读到 [300,200] 然后跑偏。
166
+ * (实测:正因为这个,有些框准、有些框飘,取决于四个数有没有重复。)
167
+ *
168
+ * 所以这里按 plist 结构解:trailer → 偏移表 → 对象表 → 数组引用 → 每个值。
169
+ */
170
+ function parseRectPlist(buf) {
171
+ if (buf.length < 40 || buf.toString('latin1', 0, 6) !== 'bplist')
172
+ return null;
173
+ const trailer = buf.subarray(buf.length - 32);
174
+ const offsetSize = trailer[6];
175
+ const refSize = trailer[7];
176
+ const numObjects = Number(trailer.readBigUInt64BE(8));
177
+ const topObject = Number(trailer.readBigUInt64BE(16));
178
+ const tableOffset = Number(trailer.readBigUInt64BE(24));
179
+ if (!numObjects || offsetSize < 1 || offsetSize > 8 || refSize < 1 || refSize > 8)
180
+ return null;
181
+ if (tableOffset + numObjects * offsetSize > buf.length)
182
+ return null;
183
+ const readSized = (at, size) => {
184
+ let value = 0;
185
+ for (let i = 0; i < size; i += 1)
186
+ value = value * 256 + buf[at + i];
187
+ return value;
188
+ };
189
+ const objectAt = (index) => {
190
+ if (index >= numObjects)
191
+ return -1;
192
+ return readSized(tableOffset + index * offsetSize, offsetSize);
193
+ };
194
+ /** 把某个对象解成一个数字(int / real / double)。 */
195
+ const readNumber = (at) => {
196
+ if (at < 0 || at >= buf.length)
197
+ return null;
198
+ const marker = buf[at];
199
+ const kind = marker >> 4;
200
+ const sizeBits = marker & 0x0f;
201
+ if (kind === 0x1) {
202
+ // int
203
+ const size = 1 << sizeBits;
204
+ if (size > 8)
205
+ return null;
206
+ let value = 0;
207
+ for (let i = 0; i < size; i += 1)
208
+ value = value * 256 + buf[at + 1 + i];
209
+ return value;
210
+ }
211
+ if (kind === 0x2) {
212
+ // real(0x22 float32 / 0x23 float64)
213
+ if (sizeBits === 2)
214
+ return buf.readFloatBE(at + 1);
215
+ if (sizeBits === 3)
216
+ return buf.readDoubleBE(at + 1);
217
+ }
218
+ return null;
219
+ };
220
+ const arrayAt = objectAt(topObject);
221
+ if (arrayAt < 0)
222
+ return null;
223
+ const marker = buf[arrayAt];
224
+ if (marker >> 4 !== 0xa)
225
+ return null;
226
+ let count = marker & 0x0f;
227
+ let refsAt = arrayAt + 1;
228
+ if (count === 0x0f) {
229
+ // 长数组:后面跟一个 int 表示数量
230
+ const intMarker = buf[refsAt];
231
+ const size = 1 << (intMarker & 0x0f);
232
+ let value = 0;
233
+ for (let i = 0; i < size; i += 1)
234
+ value = value * 256 + buf[refsAt + 1 + i];
235
+ count = value;
236
+ refsAt += 1 + size;
237
+ }
238
+ const out = [];
239
+ for (let i = 0; i < count; i += 1) {
240
+ const ref = readSized(refsAt + i * refSize, refSize);
241
+ const value = readNumber(objectAt(ref));
242
+ if (value === null)
243
+ return null;
244
+ out.push(value);
245
+ }
246
+ return out;
247
+ }
248
+ /** 从截图文件的扩展属性里读矩形(点)。 */
249
+ async function rectFromXattr(file) {
250
+ const hex = (await run('/usr/bin/xattr', ['-px', 'com.apple.metadata:kMDItemScreenCaptureGlobalRect', file])).replace(/\s+/g, '');
251
+ if (!hex || hex.length % 2 !== 0)
252
+ return null;
253
+ let buf;
254
+ try {
255
+ buf = Buffer.from(hex, 'hex');
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ const nums = parseRectPlist(buf);
261
+ if (!nums || nums.length < 4)
262
+ return null;
263
+ const [x, y, w, h] = nums;
264
+ if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0)
265
+ return null;
266
+ return { x, y, width: w, height: h };
267
+ }
268
+ /** 一张图的像素尺寸(不引图像库:读 PNG 的 IHDR 就够)。 */
269
+ async function pngSize(file) {
270
+ try {
271
+ const buf = await readFile(file);
272
+ if (buf.length < 24)
273
+ return null;
274
+ const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
275
+ if (!isPng)
276
+ return null;
277
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ function contains(win, rect) {
284
+ const cx = rect.x + rect.width / 2;
285
+ const cy = rect.y + rect.height / 2;
286
+ return cx >= win.x && cx <= win.x + win.w && cy >= win.y && cy <= win.y + win.h;
287
+ }
288
+ function area(win) {
289
+ return win.w * win.h;
290
+ }
291
+ /**
292
+ * 选"你框的那块属于哪个窗口"。
293
+ *
294
+ * 规则:layer 0(普通应用窗口)里,**包含选区中心**的那些,取面积最小的 ——
295
+ * 这样点在一个大窗口里的小对话框上,裁的是对话框而不是整个背景窗口。
296
+ */
297
+ function pickWindow(wins, rect) {
298
+ const candidates = wins
299
+ .filter((w) => w.layer === 0 && contains(w, rect))
300
+ .sort((a, b) => area(a) - area(b));
301
+ return candidates[0] || null;
302
+ }
303
+ /**
304
+ * 截一整块显示器(`screencapture -D`)。
305
+ *
306
+ * 为什么不用 `-R`:副屏在主屏左边时坐标是负的,`screencapture -R` 直接
307
+ * "could not create image from rect";屏幕锁定时 `-R` 也会失败,而 `-D` 仍然可用。
308
+ * 所以统一按显示器截,再用 sips 裁到需要的矩形。
309
+ */
310
+ async function captureDisplay(display, options, fileName) {
311
+ const dir = await ensureTmpDir();
312
+ const path = join(dir, fileName);
313
+ const index = display.index > 0 ? display.index : display.main ? 1 : 2;
314
+ await run(CAPTURE, ['-x', '-D', String(index), path]);
315
+ const size = await pngSize(path);
316
+ if (!size || display.w <= 0 || display.h <= 0) {
317
+ await rm(path, { force: true }).catch(() => { });
318
+ return null;
319
+ }
320
+ // 截错屏时宽高比通常对不上:宁可退回选区,也不要把红框画到一张不相干的图上
321
+ const expected = display.w / display.h;
322
+ const actual = size.w / size.h;
323
+ if (Math.abs(actual - expected) > expected * 0.02) {
324
+ await rm(path, { force: true }).catch(() => { });
325
+ return null;
326
+ }
327
+ return { path, size, scale: size.w / display.w };
328
+ }
329
+ /** 把显示器图按像素矩形裁出来(sips 的 --cropOffset 是 top left)。 */
330
+ async function cropPng(source, box, size, out) {
331
+ const left = Math.max(0, Math.min(size.w - 1, Math.round(box.left)));
332
+ const top = Math.max(0, Math.min(size.h - 1, Math.round(box.top)));
333
+ const width = Math.max(1, Math.min(size.w - left, Math.round(box.width)));
334
+ const height = Math.max(1, Math.min(size.h - top, Math.round(box.height)));
335
+ await run('/usr/bin/sips', ['-c', String(height), String(width), '--cropOffset', String(top), String(left), source, '--out', out], 20_000);
336
+ const cropped = await pngSize(out);
337
+ return cropped ? { left, top, width: cropped.w, height: cropped.h } : null;
338
+ }
339
+ /** 截一块选区本身(没有 region 文件时的兜底 / 自动化测试用)。 */
340
+ async function captureSelectionToFile(selection, options, fileName) {
341
+ const displays = prefetchedDisplays || (await listDisplays());
342
+ prefetchedDisplays = null;
343
+ const display = pickDisplay(displays, selection);
344
+ if (!display)
345
+ return null;
346
+ const dir = await ensureTmpDir();
347
+ const captured = await captureDisplay(display, options, `display-${options.seq}.png`);
348
+ if (!captured)
349
+ return null;
350
+ const box = {
351
+ left: (selection.x - display.x) * captured.scale,
352
+ top: (selection.y - display.y) * captured.scale,
353
+ width: selection.width * captured.scale,
354
+ height: selection.height * captured.scale,
355
+ };
356
+ const path = join(dir, fileName);
357
+ const crop = await cropPng(captured.path, box, captured.size, path);
358
+ await rm(captured.path, { force: true }).catch(() => { });
359
+ if (!crop)
360
+ return null;
361
+ return { path, size: { w: crop.width, h: crop.height }, scale: captured.scale };
362
+ }
363
+ function notifyWaiters() {
364
+ while (waiters.length > 0) {
365
+ const fn = waiters.shift();
366
+ if (fn)
367
+ fn();
368
+ }
369
+ }
370
+ async function publish(shot, shotSeq, note = '') {
371
+ state.total += 1;
372
+ state.lastError = note;
373
+ state.pending = shot;
374
+ // 成功这一次比之前那条失败更新:不要把旧错误再塞给客户端
375
+ state.pendingError = null;
376
+ state.lastShot = shot;
377
+ state.capturing = false;
378
+ notifyWaiters();
379
+ // 旧图已经没用了:客户端取图后就不再回头,保留最近几张只为重试与诊断
380
+ void pruneShots(shotSeq);
381
+ }
382
+ /**
383
+ * 截图失败(**起完截图之后**发生的失败):把一条人话放进长轮询,客户端弹提示。
384
+ *
385
+ * 用户按 Esc 取消不算失败 —— 那条路径不调用这里。
386
+ */
387
+ function fail(code, message) {
388
+ state.lastError = code;
389
+ state.pendingError = { code, message, at: Date.now() };
390
+ state.capturing = false;
391
+ notifyWaiters();
392
+ }
393
+ /** 删除 seq 之前的旧临时图(只删本插件自己的命名,不动目录里的其它东西)。 */
394
+ async function pruneShots(currentSeq) {
395
+ try {
396
+ const dir = tmpDir();
397
+ const names = await readdir(dir);
398
+ for (const name of names) {
399
+ const match = /^(?:region|shot|display)-(\d+)\.png$/.exec(name);
400
+ if (!match)
401
+ continue;
402
+ if (Number(match[1]) <= currentSeq - KEEP_SHOTS) {
403
+ await rm(join(dir, name), { force: true }).catch(() => { });
404
+ }
405
+ }
406
+ }
407
+ catch {
408
+ /* 清不掉不影响功能 */
409
+ }
410
+ }
411
+ /**
412
+ * 仅选区:框选落下的 region 文件**就是**最终图。
413
+ *
414
+ * 不需要窗口列表、不需要 xattr、不需要第二次截图;客户端也不用画布。
415
+ */
416
+ async function finishSelection(regionFile, selection, options, note = '') {
417
+ const size = await pngSize(regionFile);
418
+ if (!size) {
419
+ fail('image-missing', '截图已经拍下,但读不到图片文件,请重试');
420
+ return;
421
+ }
422
+ const sel = selection || { x: 0, y: 0, width: size.w, height: size.h };
423
+ const scale = sel.width > 0 ? size.w / sel.width : 1;
424
+ const shot = {
425
+ id: `shot-${options.seq}`,
426
+ at: Date.now(),
427
+ scope: 'selection',
428
+ mode: options.mode,
429
+ frame: false,
430
+ imagePath: regionFile,
431
+ rect: { x: 0, y: 0, width: size.w, height: size.h },
432
+ windowRect: sel,
433
+ selectionRect: sel,
434
+ scale,
435
+ pad: { x: 0, y: 0 },
436
+ owner: '',
437
+ ready: true,
438
+ };
439
+ await publish(shot, options.seq, note);
440
+ }
441
+ /** 回退到「仅选区」:有 region 就用它,没有就现截一块。 */
442
+ async function fallbackSelection(selection, options, regionFile, note) {
443
+ if (regionFile)
444
+ return finishSelection(regionFile, selection, options, note);
445
+ const captured = await captureSelectionToFile(selection, options, `region-${options.seq}.png`);
446
+ if (captured)
447
+ return finishSelection(captured.path, selection, options, note);
448
+ fail(note || 'selection-failed', '截图已拍下,但读取图片失败,请重试');
449
+ }
450
+ /** 选区 + 留白:外扩后裁到选区中心所在的那块屏,再从整屏图里裁出来。 */
451
+ async function finishMargin(selection, options, regionFile, note = '') {
452
+ const margin = Math.max(0, Math.min(MAX_MARGIN_PT, Math.round(options.margin)));
453
+ if (margin <= 0)
454
+ return fallbackSelection(selection, options, regionFile, note);
455
+ let expanded = {
456
+ x: selection.x - margin,
457
+ y: selection.y - margin,
458
+ width: selection.width + margin * 2,
459
+ height: selection.height + margin * 2,
460
+ };
461
+ const displays = prefetchedDisplays || (await listDisplays());
462
+ prefetchedDisplays = null;
463
+ const display = pickDisplay(displays, selection);
464
+ if (!display)
465
+ return fallbackSelection(selection, options, regionFile, note || 'no-display');
466
+ expanded = clampRect(expanded, display);
467
+ if (expanded.width < 2 || expanded.height < 2) {
468
+ return fallbackSelection(selection, options, regionFile, note || 'margin-clipped');
469
+ }
470
+ const captured = await captureDisplay(display, options, `display-${options.seq}.png`);
471
+ if (!captured) {
472
+ return fallbackSelection(selection, options, regionFile, note || 'margin-capture-failed');
473
+ }
474
+ const scale = captured.scale;
475
+ const dir = await ensureTmpDir();
476
+ const imagePath = join(dir, `shot-${options.seq}.png`);
477
+ const crop = await cropPng(captured.path, {
478
+ left: (expanded.x - display.x) * scale,
479
+ top: (expanded.y - display.y) * scale,
480
+ width: expanded.width * scale,
481
+ height: expanded.height * scale,
482
+ }, captured.size, imagePath);
483
+ await rm(captured.path, { force: true }).catch(() => { });
484
+ if (!crop) {
485
+ return fallbackSelection(selection, options, regionFile, note || 'margin-crop-failed');
486
+ }
487
+ const shot = {
488
+ id: `shot-${options.seq}`,
489
+ at: Date.now(),
490
+ scope: 'margin',
491
+ mode: options.mode,
492
+ frame: true,
493
+ imagePath,
494
+ rect: {
495
+ // 用裁切后的实际像素原点,避免 sips 取整带来的 1px 偏移
496
+ x: Math.round((selection.x - display.x) * scale) - crop.left,
497
+ y: Math.round((selection.y - display.y) * scale) - crop.top,
498
+ width: Math.round(selection.width * scale),
499
+ height: Math.round(selection.height * scale),
500
+ },
501
+ windowRect: expanded,
502
+ selectionRect: selection,
503
+ scale,
504
+ pad: { x: 0, y: 0 },
505
+ owner: '',
506
+ ready: true,
507
+ };
508
+ await publish(shot, options.seq, note);
509
+ }
510
+ /** 全屏:选区中心所在的那块显示器整屏截下来。 */
511
+ async function finishScreen(selection, options, regionFile) {
512
+ const displays = prefetchedDisplays || (await listDisplays());
513
+ prefetchedDisplays = null;
514
+ const display = pickDisplay(displays, selection);
515
+ if (!display) {
516
+ state.lastError = 'no-display';
517
+ return finishMargin(selection, options, regionFile, 'no-display');
518
+ }
519
+ const captured = await captureDisplay(display, options, `shot-${options.seq}.png`);
520
+ if (!captured) {
521
+ state.lastError = 'screen-capture-failed';
522
+ return finishMargin(selection, options, regionFile, 'screen-capture-failed');
523
+ }
524
+ const scale = captured.scale;
525
+ const shot = {
526
+ id: `shot-${options.seq}`,
527
+ at: Date.now(),
528
+ scope: 'screen',
529
+ mode: options.mode,
530
+ frame: true,
531
+ imagePath: captured.path,
532
+ rect: {
533
+ x: Math.round((selection.x - display.x) * scale),
534
+ y: Math.round((selection.y - display.y) * scale),
535
+ width: Math.round(selection.width * scale),
536
+ height: Math.round(selection.height * scale),
537
+ },
538
+ windowRect: { x: display.x, y: display.y, width: display.w, height: display.h },
539
+ selectionRect: selection,
540
+ scale,
541
+ pad: { x: 0, y: 0 },
542
+ owner: '',
543
+ ready: true,
544
+ };
545
+ await publish(shot, options.seq);
546
+ }
547
+ /** 整窗:找窗口、整窗截图;找不到或探针缺失时退到「选区 + 留白」。 */
548
+ async function finishWindow(regionFile, selection, options) {
549
+ if (!(await probeAvailable())) {
550
+ state.lastError = 'probe-missing';
551
+ return finishMargin(selection, options, regionFile, 'probe-missing');
552
+ }
553
+ const wins = prefetchedWindows || (await listWindows());
554
+ prefetchedWindows = null;
555
+ const win = pickWindow(wins, selection);
556
+ if (!win) {
557
+ state.lastError = 'no-window';
558
+ return finishMargin(selection, options, regionFile, 'no-window');
559
+ }
560
+ const dir = await ensureTmpDir();
561
+ const imagePath = join(dir, `shot-${options.seq}.png`);
562
+ // ⚠️ 不要加 `-o`:文档说它"不截窗口阴影",实测**反过来** ——
563
+ // 加了是 3840×2224(多 64px 阴影),不加才是 3840×2160(正好窗口的 2 倍)。
564
+ // 阴影上下不对称,按对半分会让红框整体偏(用户实测:向上偏移)。
565
+ await run(CAPTURE, ['-l', String(win.id), '-x', imagePath]);
566
+ const size = await pngSize(imagePath);
567
+ if (!size) {
568
+ state.lastError = 'window-capture-failed';
569
+ return finishMargin(selection, options, regionFile, 'window-capture-failed');
570
+ }
571
+ const scale = win.w > 0 ? size.w / win.w : 2;
572
+ const windowRect = { x: win.x, y: win.y, width: win.w, height: win.h };
573
+ // 正常情况 size 应当正好等于窗口的倍数(pad=0);万一系统还是带了阴影,
574
+ // 就按"多出来的两半分"兜底 —— 客户端的 alpha 内容区探测会再校正一次。
575
+ const pad = {
576
+ x: Math.max(0, Math.round((size.w - win.w * scale) / 2)),
577
+ y: Math.max(0, Math.round((size.h - win.h * scale) / 2)),
578
+ };
579
+ const shot = {
580
+ id: `shot-${options.seq}`,
581
+ at: Date.now(),
582
+ scope: 'window',
583
+ mode: options.mode,
584
+ frame: true,
585
+ imagePath,
586
+ rect: {
587
+ x: Math.round(pad.x + (selection.x - windowRect.x) * scale),
588
+ y: Math.round(pad.y + (selection.y - windowRect.y) * scale),
589
+ width: Math.round(selection.width * scale),
590
+ height: Math.round(selection.height * scale),
591
+ },
592
+ windowRect,
593
+ selectionRect: selection,
594
+ scale,
595
+ pad,
596
+ owner: win.owner,
597
+ ready: true,
598
+ };
599
+ await publish(shot, options.seq);
600
+ }
601
+ /**
602
+ * 用户松手后的分派。
603
+ *
604
+ * 顺序仍然刻意:**先出图,后发事件**。UI 一旦画出来,`screencapture` 就会把它截进去
605
+ * (框、卡片都算页面内容)。仅选区的图在框选时就已经落好了,没有这个顾虑。
606
+ */
607
+ async function finishInteractive(regionFile, options, knownRect) {
608
+ let selection = knownRect || null;
609
+ if (!selection && options.scope !== 'selection') {
610
+ selection = await rectFromXattr(regionFile);
611
+ }
612
+ if (options.scope === 'selection') {
613
+ const rect = selection || (await rectFromXattr(regionFile));
614
+ return finishSelection(regionFile, rect, options);
615
+ }
616
+ if (!selection) {
617
+ state.lastError = 'no-rect';
618
+ return finishSelection(regionFile, null, options, 'no-rect');
619
+ }
620
+ switch (options.scope) {
621
+ case 'margin':
622
+ return finishMargin(selection, options, regionFile);
623
+ case 'screen':
624
+ return finishScreen(selection, options, regionFile);
625
+ case 'window':
626
+ default:
627
+ return finishWindow(regionFile, selection, options);
628
+ }
629
+ }
630
+ /** 把 screencapture 的 stderr 翻成用户能照做的一句话。 */
631
+ function friendlyCaptureError(stderr, code) {
632
+ const text = stderr.toLowerCase();
633
+ if (text.includes('not authorized') || text.includes('permission') || text.includes('could not create image')) {
634
+ return '没有屏幕录制权限:请在 系统设置 → 隐私与安全性 → 屏幕录制 中允许 DSH,然后重试';
635
+ }
636
+ const tail = stderr.trim().replace(/\s+/g, ' ').slice(0, 160);
637
+ return `系统截图失败(退出码 ${code ?? '未知'})${tail ? `:${tail}` : ''}`;
638
+ }
639
+ /** 起一次交互式框选。 */
640
+ async function startInteractive(options) {
641
+ if (!ensureCaptureAvailable())
642
+ return false;
643
+ if (state.capturing)
644
+ return false;
645
+ state.capturing = true;
646
+ const shotSeq = ++seq;
647
+ const opts = { ...options, seq: shotSeq };
648
+ let dir;
649
+ try {
650
+ dir = await ensureTmpDir();
651
+ }
652
+ catch (error) {
653
+ state.lastError = `tmp-failed: ${String(error?.message ?? error)}`;
654
+ state.capturing = false;
655
+ return false;
656
+ }
657
+ const regionFile = join(dir, `region-${shotSeq}.png`);
658
+ await rm(regionFile, { force: true }).catch(() => { });
659
+ // 和框选并行:用户拖框那一两秒里,窗口 / 显示器列表早就拿好了
660
+ prefetchedWindows = null;
661
+ prefetchedDisplays = null;
662
+ if (opts.scope === 'window') {
663
+ void listWindows()
664
+ .then((wins) => {
665
+ prefetchedWindows = wins;
666
+ })
667
+ .catch(() => { });
668
+ }
669
+ if (opts.scope !== 'selection') {
670
+ void listDisplays()
671
+ .then((displays) => {
672
+ prefetchedDisplays = displays;
673
+ })
674
+ .catch(() => { });
675
+ }
676
+ const child = spawn(CAPTURE, ['-i', '-x', regionFile], { stdio: ['ignore', 'ignore', 'pipe'] });
677
+ let stderr = '';
678
+ let timedOut = false;
679
+ child.stderr?.on('data', (chunk) => {
680
+ if (stderr.length < 4000)
681
+ stderr += String(chunk);
682
+ });
683
+ const timer = setTimeout(() => {
684
+ timedOut = true;
685
+ try {
686
+ child.kill('SIGTERM');
687
+ }
688
+ catch {
689
+ /* already gone */
690
+ }
691
+ }, SELECT_TIMEOUT_MS);
692
+ unrefTimer(timer);
693
+ child.on('error', () => {
694
+ clearTimeout(timer);
695
+ fail('spawn-failed', '无法启动系统截图命令(screencapture)');
696
+ });
697
+ child.on('close', (code, signal) => {
698
+ clearTimeout(timer);
699
+ // stat 失败(没有文件)不等于程序异常:先当成"没拍到",下面按退出码/stderr 区分
700
+ void stat(regionFile)
701
+ .catch(() => null)
702
+ .then((s) => {
703
+ // spawn 失败已经报过('error' 之后仍可能来一条 close)
704
+ if (state.pendingError)
705
+ return undefined;
706
+ if (s && s.size > 0)
707
+ return finishInteractive(regionFile, opts);
708
+ if (timedOut || signal === 'SIGTERM') {
709
+ fail('timeout', '框选超时,已取消这次截图');
710
+ }
711
+ else if ((typeof code === 'number' && code !== 0) || stderr.trim()) {
712
+ // 非 0 退出码或 stderr 都说明系统截图失败;退出码 0 + 无输出才是用户按 Esc
713
+ fail('capture-failed', friendlyCaptureError(stderr, code));
714
+ }
715
+ return undefined;
716
+ })
717
+ .catch((error) => {
718
+ // 已经发布过结果(例如回退成功)就不要再追加一条失败
719
+ const shotId = `shot-${shotSeq}`;
720
+ if (state.pending?.id === shotId || state.lastShot?.id === shotId)
721
+ return;
722
+ const message = String(error?.message ?? error);
723
+ fail('finish-failed', `截图已拍下,但处理失败:${message}`);
724
+ })
725
+ .finally(() => {
726
+ state.capturing = false;
727
+ prefetchedWindows = null;
728
+ prefetchedDisplays = null;
729
+ });
730
+ });
731
+ return true;
732
+ }
733
+ /** 开发/自动化用:跳过交互式框选,直接按给定矩形截一块再走同一条收尾流程。 */
734
+ async function startWithRect(rect, options) {
735
+ if (!ensureCaptureAvailable())
736
+ return false;
737
+ if (state.capturing)
738
+ return false;
739
+ state.capturing = true;
740
+ const shotSeq = ++seq;
741
+ const opts = { ...options, seq: shotSeq };
742
+ // 自动化路径也用"整屏 + 裁切",与真实框选走出同一套坐标:不再依赖 `-R`
743
+ try {
744
+ const captured = await captureSelectionToFile(rect, opts, `region-${shotSeq}.png`);
745
+ if (!captured) {
746
+ state.capturing = false;
747
+ state.lastError = 'capture-failed';
748
+ return false;
749
+ }
750
+ await finishInteractive(captured.path, opts, rect);
751
+ return true;
752
+ }
753
+ catch (error) {
754
+ state.lastError = `start-failed: ${String(error?.message ?? error)}`;
755
+ return false;
756
+ }
757
+ finally {
758
+ state.capturing = false;
759
+ }
760
+ }
761
+ const DEFAULT_SHOT_CONFIG = {
762
+ shotCardScope: 'window',
763
+ shotPanelScope: 'selection',
764
+ shotMargin: 48,
765
+ };
766
+ function resolveOptions(config, mode, url) {
767
+ const override = url.searchParams.get('scope');
768
+ const stored = mode === 'panel' ? config.shotPanelScope : config.shotCardScope;
769
+ const raw = override && SHOT_SCOPES.includes(override) ? override : stored;
770
+ const scope = SHOT_SCOPES.includes(raw) ? raw : mode === 'panel' ? 'selection' : 'window';
771
+ const marginParam = url.searchParams.get('margin');
772
+ const marginValue = marginParam === null ? NaN : Number(marginParam);
773
+ const fallback = Number.isFinite(config.shotMargin) ? config.shotMargin : 48;
774
+ const margin = Number.isFinite(marginValue)
775
+ ? Math.max(0, Math.min(MAX_MARGIN_PT, Math.round(marginValue)))
776
+ : Math.max(0, Math.min(MAX_MARGIN_PT, Math.round(fallback)));
777
+ return { mode, scope, margin };
778
+ }
779
+ /** `POST /api/shot/start` —— 开始一次截图(可带 `?rect=x,y,w,h&scope=…` 供测试)。 */
780
+ export async function handleShotStart(req, res, config = DEFAULT_SHOT_CONFIG) {
781
+ if (req.method !== 'POST') {
782
+ sendJson(res, 405, { ok: false, reason: 'method-not-allowed' });
783
+ return;
784
+ }
785
+ // 新的一次尝试覆盖上一次还没取走的失败提示
786
+ state.pendingError = null;
787
+ const url = new URL(req.url || '/', 'http://localhost');
788
+ const rectParam = url.searchParams.get('rect');
789
+ const mode = url.searchParams.get('mode') === 'panel' ? 'panel' : 'card';
790
+ try {
791
+ await readBody(req, 64 * 1024).catch(() => '');
792
+ }
793
+ catch {
794
+ /* 没有 body 也无所谓 */
795
+ }
796
+ if (!ensureCaptureAvailable()) {
797
+ sendJson(res, 200, { ok: false, reason: state.reason });
798
+ return;
799
+ }
800
+ if (state.capturing) {
801
+ sendJson(res, 200, { ok: false, reason: 'busy' });
802
+ return;
803
+ }
804
+ const options = resolveOptions(config, mode, url);
805
+ if (rectParam) {
806
+ const n = rectParam.split(',').map(Number);
807
+ if (n.length === 4 && n.every(Number.isFinite) && n[2] > 0 && n[3] > 0) {
808
+ const started = await startWithRect({ x: n[0], y: n[1], width: n[2], height: n[3] }, options);
809
+ sendJson(res, 200, {
810
+ ok: started,
811
+ test: true,
812
+ mode,
813
+ scope: options.scope,
814
+ margin: options.margin,
815
+ ...(started ? {} : { reason: state.lastError || 'start-failed' }),
816
+ });
817
+ return;
818
+ }
819
+ }
820
+ const started = await startInteractive(options);
821
+ sendJson(res, 200, {
822
+ ok: started,
823
+ mode,
824
+ scope: options.scope,
825
+ margin: options.margin,
826
+ ...(started ? {} : { reason: state.lastError || 'start-failed' }),
827
+ });
828
+ }
829
+ /** `GET /api/shot/pending` —— 长轮询,等下一次截图结果或失败原因(最多 25 秒)。 */
830
+ export function handleShotPending(_req, res) {
831
+ const takeShot = () => {
832
+ const shot = state.pending;
833
+ state.pending = null;
834
+ return shot;
835
+ };
836
+ const takeError = () => {
837
+ const error = state.pendingError;
838
+ state.pendingError = null;
839
+ return error;
840
+ };
841
+ const reply = () => {
842
+ sendJson(res, 200, { ok: true, shot: takeShot(), error: takeError(), status: shotStatus() });
843
+ };
844
+ if (state.pending || state.pendingError) {
845
+ reply();
846
+ return;
847
+ }
848
+ const timer = setTimeout(() => {
849
+ const i = waiters.indexOf(done);
850
+ if (i >= 0)
851
+ waiters.splice(i, 1);
852
+ reply();
853
+ }, 25_000);
854
+ // 长轮询只是"等客户端结果",绝不能把一个挂着它的 Host 进程钉在事件循环里:
855
+ // 桌面端更新/退出只给 Host 10 秒优雅期限,未 unref 的 25s 定时器会让外壳判定
856
+ // "Host did not complete graceful task teardown" 并拒绝安装更新。
857
+ unrefTimer(timer);
858
+ const done = () => {
859
+ clearTimeout(timer);
860
+ reply();
861
+ };
862
+ waiters.push(done);
863
+ }
864
+ /** `GET /api/shot/image?id=` —— 把图按字节回给客户端(不进 JSON,避免几 MB 的 base64)。 */
865
+ export async function handleShotImage(req, res) {
866
+ const url = new URL(req.url || '/', 'http://localhost');
867
+ const id = url.searchParams.get('id') || '';
868
+ const shot = state.pending && state.pending.id === id
869
+ ? state.pending
870
+ : state.lastShot && state.lastShot.id === id
871
+ ? state.lastShot
872
+ : null;
873
+ if (!shot) {
874
+ res.statusCode = 404;
875
+ res.end('shot-not-found');
876
+ return;
877
+ }
878
+ // 图已经落盘(ready 恒为 true);保留这段等待是为了兼容"先发事件、后出图"的未来实现
879
+ const deadline = Date.now() + 4000;
880
+ while (!shot.ready && Date.now() < deadline) {
881
+ await new Promise((r) => setTimeout(r, 50));
882
+ }
883
+ if (!shot.ready) {
884
+ res.statusCode = 404;
885
+ res.end('not-ready');
886
+ return;
887
+ }
888
+ try {
889
+ const buf = await readFile(shot.imagePath);
890
+ res.statusCode = 200;
891
+ res.setHeader('content-type', 'image/png');
892
+ res.setHeader('cache-control', 'no-store');
893
+ res.end(buf);
894
+ }
895
+ catch {
896
+ res.statusCode = 500;
897
+ res.end('read-failed');
898
+ }
899
+ }
900
+ /** 客户端加载时回报的自检结果(开发期用,只存内存)。 */
901
+ export let clientHello = null;
902
+ /** 按 stage 保留最近一次上报:'geometry' 这种关键诊断不会被后面的 'uploaded' 覆盖。 */
903
+ export const clientReports = {};
904
+ export function handleShotHello(req, res) {
905
+ const chunks = [];
906
+ req.on('data', (c) => chunks.push(c));
907
+ req.on('end', () => {
908
+ try {
909
+ clientHello = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
910
+ const stage = clientHello?.stage;
911
+ if (stage)
912
+ clientReports[stage] = clientHello;
913
+ }
914
+ catch {
915
+ clientHello = { parseError: true };
916
+ }
917
+ sendJson(res, 200, { ok: true });
918
+ });
919
+ }
920
+ export function shotStatus() {
921
+ return {
922
+ available: state.available,
923
+ reason: state.reason,
924
+ capturing: state.capturing,
925
+ total: state.total,
926
+ lastError: state.lastError,
927
+ };
928
+ }
929
+ /** 插件卸载 / 热重载:把临时目录清掉(截图是临时的,没有留着的理由)。 */
930
+ export async function stopShot() {
931
+ try {
932
+ await rm(tmpDir(), { recursive: true, force: true });
933
+ }
934
+ catch {
935
+ /* 清不掉也不影响功能 */
936
+ }
937
+ }
938
+ /** `GET /api/shot/status` —— 调试/自检。 */
939
+ export async function handleShotStatus(_req, res) {
940
+ ensureCaptureAvailable();
941
+ const probe = await probeAvailable();
942
+ const last = state.lastShot;
943
+ sendJson(res, 200, {
944
+ ok: true,
945
+ status: shotStatus(),
946
+ windowProbe: probe,
947
+ clientHello,
948
+ clientReports,
949
+ // 只读观察点:不清 pending,用来量"事件发出 → 图做好"这一段
950
+ lastShot: last
951
+ ? {
952
+ id: last.id,
953
+ scope: last.scope,
954
+ mode: last.mode,
955
+ frame: last.frame,
956
+ ready: last.ready,
957
+ ageMs: Date.now() - last.at,
958
+ owner: last.owner,
959
+ rect: last.rect,
960
+ pad: last.pad,
961
+ scale: last.scale,
962
+ selectionRect: last.selectionRect,
963
+ windowRect: last.windowRect,
964
+ }
965
+ : null,
966
+ });
967
+ }
968
+ //# sourceMappingURL=shot.js.map