@lyra_wang/dsh-screenshot 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lyra Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # @lyra_wang/dsh-screenshot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 输入框左侧多一个「+ 截图」按钮:按下 → 系统框选屏幕(空格可切换成「选窗口」)→ 截好的图**直接作为附件进入草稿**,不用剪贴板、不用切 App。
4
+
5
+ 对齐豆包工作 / Codex 的「应用截图」用法,但走的是 DSH 自己的附件管线。
6
+
7
+ ## 为什么是 Host + Client 两半
8
+
9
+ | 半边 | 干什么 | 为什么不能放在一起 |
10
+ |---|---|---|
11
+ | Host (`index.js`) | 注册 `POST /plugins/screenshot/capture`,调 macOS `screencapture`,把 PNG 原样返回 | 浏览器页面无法打开系统截图 UI;`screencapture` 是同一 GUI 会话里的普通 CLI |
12
+ | Client (`client.js`) | 在 `conversation.input.left` 注册按钮;拿到 PNG 后交给输入框 | 草稿附件的 id 与上传状态由 composer 自己持有,插件只能把 `File` 交回给它的 intake 路径 |
13
+
14
+ Client 侧**不直接调上传 API**:`ctx.fileUpload.upload()` 只返回服务端凭据,而草稿附件需要客户端自己生成 id + 上传状态表。把图片做成 `File` 后合成一次 `paste` 事件(`ClipboardEvent` + `DataTransfer`),输入框就会走和用户手动粘贴完全相同的代码路径:上传 → 缩略图 → 可删除附件。找不到编辑器时退回「文件选择 input + change 事件」。
15
+
16
+ ## 前台条件
17
+
18
+ - macOS(使用系统 `screencapture`)
19
+ - DSH Desktop 需要**屏幕录制**权限(系统设置 → 隐私与安全性 → 屏幕录制)。Host 进程实测可直接调用 `screencapture`;如果截图返回空白/失败,先检查这一项。
20
+
21
+ ## 用法
22
+
23
+ 1. 在对话输入框左侧点「+ 截图」。
24
+ 2. 屏幕出现框选十字:拖拽选区域;按 **空格** 切换成窗口选择;按 **Esc** 取消。
25
+ 3. 松手后图片出现在草稿的附件区,和粘贴截图完全一样(可删除、可随消息发出)。
26
+
27
+ 顶部会有一条短暂提示告诉你当前状态(框选中 / 已加入 / 出错原因)。
28
+
29
+ ## 实现要点
30
+
31
+ - `screencapture -i -x -t png <tmp>`:`-i` 交互、`-x` 静音、显式 PNG;退出码非 0 且没有产物 = 用户取消 → 路由返回 `204`,客户端静默复位。
32
+ - 同一时刻只允许一次截图(并发请求返回 `409`),避免两个系统选区叠在一起。
33
+ - 临时文件写在 `mkdtemp` 出来的目录里,返回后立即删除。
34
+ - 路由复用 Host 的连接鉴权(`connection.requestRejection`),只有 DSH 页面能调。
35
+
36
+ ## 已知边界
37
+
38
+ - 只能在 DSH **桌面端**用:Web 版没有 Host 进程的 GUI 会话,`screencapture` 不可用。
39
+ - 截图期间那个 HTTP 请求会一直挂着(最长 180 秒超时),这是「等用户选完」的必然形态。
40
+ - 依赖 DSH composer 的粘贴处理器;若未来 composer 结构变化导致合成 paste 不再被接收,会退回文件 input 通道,仍不行则报「找不到输入框」。
41
+
42
+ ## 回滚
43
+
44
+ ```bash
45
+ # 从 profile 移除(等价于插件页面里的卸载)
46
+ dsh plugin remove @lyra_wang/dsh-screenshot
47
+ ```
48
+
49
+ 或直接删掉 `~/.dsh/profiles/desktop/package.json` 里对应的依赖与 `dsh.profile.bundles` 条目,然后重启 DSH。
package/client.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Client half of the screenshot bundle.
3
+ *
4
+ * One compact button in `conversation.input.left`: press it, the Host opens the
5
+ * system capture UI (crosshair, Space toggles window picking), and the finished
6
+ * PNG is pushed into the draft as an ordinary image attachment.
7
+ *
8
+ * Why a synthetic paste instead of a direct upload call: the composer owns the
9
+ * draft-attachment registry (client-generated ids + upload progress), so the
10
+ * only supported way for a plugin to add an image exactly like a user paste is
11
+ * to hand the same `File` to that intake path. `ClipboardEvent` carries a
12
+ * `DataTransfer`, so the composer's own paste handler does the rest.
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: '@lyra_wang/dsh-screenshot',
16
+ factory(require) {
17
+ const React = require('react');
18
+ const h = React.createElement;
19
+ const { useCallback, useEffect, useRef, useState } = React;
20
+
21
+ const ROOT = 'dsh-screenshot';
22
+ const CAPTURE_PATH = '/plugins/screenshot/capture';
23
+
24
+ const CSS = `
25
+ .${ROOT}-btn{all:unset;cursor:pointer;display:inline-flex;align-items:center;gap:5px;
26
+ height:26px;padding:0 8px;border-radius:7px;color:var(--dsw-alias-label-secondary,#b9b9c0);
27
+ font:12px/1 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;user-select:none}
28
+ .${ROOT}-btn:hover{background:var(--dsw-alias-bg-layer-2,#3a3a41);color:var(--dsw-alias-label-primary,#e6e6e9)}
29
+ .${ROOT}-btn[data-busy="true"]{opacity:.6;cursor:progress}
30
+ .${ROOT}-toast{position:fixed;z-index:2147483000;left:50%;transform:translateX(-50%);bottom:86px;
31
+ padding:8px 14px;border-radius:999px;background:var(--dsw-alias-bg-overlay,#2b2b2f);
32
+ border:1px solid var(--dsw-alias-border-l1,#3a3a40);box-shadow:0 10px 30px rgba(0,0,0,.4);
33
+ font:12px/1.3 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;
34
+ color:var(--dsw-alias-label-primary,#e6e6e9);pointer-events:none;white-space:nowrap}
35
+ .${ROOT}-toast[data-error="true"]{color:#ff8a8a}
36
+ `;
37
+
38
+ /** Walk up from the button until an ancestor contains the draft editor. */
39
+ function findComposerScope(node) {
40
+ let current = node;
41
+ while (current && current !== document.body) {
42
+ if (current.querySelector && current.querySelector('[contenteditable="true"], input[type="file"]')) {
43
+ return current;
44
+ }
45
+ current = current.parentElement;
46
+ }
47
+ return document.body;
48
+ }
49
+
50
+ function findEditor(scope) {
51
+ const active = document.activeElement;
52
+ if (active && active.matches && active.matches('[contenteditable="true"]')) return active;
53
+ return scope.querySelector('[contenteditable="true"]')
54
+ || document.querySelector('[contenteditable="true"]');
55
+ }
56
+
57
+ function findFileInput(scope) {
58
+ return scope.querySelector('input[type="file"]')
59
+ || document.querySelector('input[type="file"]');
60
+ }
61
+
62
+ /**
63
+ * Hand one image File to the composer the way a user paste would.
64
+ * @returns which intake path accepted the file.
65
+ */
66
+ function deliverImage(scope, file) {
67
+ const editor = findEditor(scope);
68
+ if (editor) {
69
+ editor.focus({ preventScroll: true });
70
+ const dataTransfer = new DataTransfer();
71
+ dataTransfer.items.add(file);
72
+ const event = new ClipboardEvent('paste', {
73
+ clipboardData: dataTransfer,
74
+ bubbles: true,
75
+ cancelable: true,
76
+ });
77
+ editor.dispatchEvent(event);
78
+ return 'paste';
79
+ }
80
+ const input = findFileInput(scope);
81
+ if (input) {
82
+ const dataTransfer = new DataTransfer();
83
+ dataTransfer.items.add(file);
84
+ input.files = dataTransfer.files;
85
+ input.dispatchEvent(new Event('change', { bubbles: true }));
86
+ return 'input';
87
+ }
88
+ throw new Error('找不到输入框,无法放入截图');
89
+ }
90
+
91
+ function ScreenshotButton(props) {
92
+ const [busy, setBusy] = useState(false);
93
+ const [toast, setToast] = useState(null);
94
+ const btnRef = useRef(null);
95
+ const toastTimer = useRef(0);
96
+
97
+ const showToast = useCallback((text, isError) => {
98
+ setToast({ text, isError: isError === true });
99
+ window.clearTimeout(toastTimer.current);
100
+ toastTimer.current = window.setTimeout(() => setToast(null), isError ? 5200 : 2200);
101
+ }, []);
102
+
103
+ useEffect(() => () => window.clearTimeout(toastTimer.current), []);
104
+
105
+ const capture = useCallback(async () => {
106
+ if (busy) return;
107
+ const scope = findComposerScope(btnRef.current);
108
+ setBusy(true);
109
+ showToast('框选要截的区域(空格可切窗口,Esc 取消)');
110
+ try {
111
+ const response = await fetch(CAPTURE_PATH, {
112
+ method: 'POST',
113
+ headers: { 'content-type': 'application/json' },
114
+ body: JSON.stringify({ mode: 'region' }),
115
+ });
116
+ if (response.status === 204) {
117
+ showToast('已取消截图');
118
+ return;
119
+ }
120
+ if (!response.ok) {
121
+ const payload = await response.json().catch(() => ({}));
122
+ throw new Error(payload.message || ('HTTP ' + response.status));
123
+ }
124
+ const blob = await response.blob();
125
+ if (blob.size === 0) throw new Error('截图内容为空');
126
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
127
+ const file = new File([blob], 'screenshot-' + stamp + '.png', { type: 'image/png' });
128
+ const via = deliverImage(scope, file);
129
+ showToast(via === 'paste' ? '截图已加入草稿' : '截图已加入草稿(文件选择通道)');
130
+ } catch (error) {
131
+ showToast(error instanceof Error ? error.message : String(error), true);
132
+ } finally {
133
+ setBusy(false);
134
+ }
135
+ }, [busy, showToast]);
136
+
137
+ const button = h('button', {
138
+ key: 'btn',
139
+ ref: btnRef,
140
+ type: 'button',
141
+ className: ROOT + '-btn',
142
+ 'data-busy': busy ? 'true' : 'false',
143
+ title: '截图并放入输入框(框选区域,空格切换窗口)',
144
+ onClick: capture,
145
+ }, h('span', null, '+'), h('span', null, busy ? '截图中…' : '截图'));
146
+
147
+ const toastNode = toast
148
+ ? h('div', {
149
+ key: 'toast',
150
+ className: ROOT + '-toast',
151
+ 'data-error': toast.isError ? 'true' : 'false',
152
+ }, toast.text)
153
+ : null;
154
+
155
+ return h(React.Fragment, null, h('style', { key: 'style' }, CSS), button, toastNode);
156
+ }
157
+
158
+ return {
159
+ inject: ['slots'],
160
+ apply(ctx) {
161
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
162
+ name: 'conversation.input.left',
163
+ id: 'screenshot',
164
+ order: 4,
165
+ }, (props) => h(ScreenshotButton, props)));
166
+ },
167
+ };
168
+ },
169
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: screenshot
3
+ name: '@lyra_wang/dsh-screenshot'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect x="6" y="16" width="52" height="36" rx="8" fill="none" stroke="#7aa2ff" stroke-width="4"/>
3
+ <circle cx="32" cy="34" r="10" fill="none" stroke="#7aa2ff" stroke-width="4"/>
4
+ <path d="M22 16l3-6h14l3 6" fill="none" stroke="#7aa2ff" stroke-width="4" stroke-linejoin="round"/>
5
+ <circle cx="48" cy="24" r="2.5" fill="#7aa2ff"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Host half of the screenshot bundle.
3
+ *
4
+ * One route: run macOS `screencapture` and hand the client the raw PNG bytes.
5
+ *
6
+ * Why the host and not the browser: a page cannot open the system capture UI.
7
+ * `screencapture` is a normal CLI in the same GUI session as the Host, so a
8
+ * region/window selection appears on top of everything and returns when the
9
+ * operator finishes (Esc cancels).
10
+ *
11
+ * `-i` = interactive (crosshair; Space toggles window-picking mode),
12
+ * `-x` = no shutter sound, `-t png` = explicit format.
13
+ */
14
+ import { execFile } from 'node:child_process';
15
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ export const inject = ['webServer'];
20
+
21
+ const CAPTURE_PATH = '/plugins/screenshot/capture';
22
+ const CAPTURE_TIMEOUT_MS = 180_000;
23
+
24
+ let capturing = false;
25
+
26
+ function sendJson(res, status, payload) {
27
+ res.setHeader('content-type', 'application/json; charset=utf-8');
28
+ res.writeHead(status);
29
+ res.end(JSON.stringify(payload));
30
+ }
31
+
32
+ /** Reuse the Host's browser authentication for this plugin's own route. */
33
+ function connectionRejection(ctx, req) {
34
+ const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
35
+ if (!connection || typeof connection.requestRejection !== 'function') return undefined;
36
+ try {
37
+ return connection.requestRejection({ headers: req.headers });
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Run screencapture into a temp file.
45
+ * @param mode - "region" for the interactive crosshair, "full" for a silent
46
+ * whole-screen capture (kept for self-tests and headless verification).
47
+ * @returns PNG bytes, or null when the operator cancelled.
48
+ */
49
+ function capturePng(mode) {
50
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-screenshot-'));
51
+ const file = join(dir, 'capture.png');
52
+ const args = mode === 'full' ? ['-x', '-t', 'png', file] : ['-i', '-x', '-t', 'png', file];
53
+ return new Promise((resolve, reject) => {
54
+ execFile('/usr/sbin/screencapture', args, { timeout: CAPTURE_TIMEOUT_MS }, (error) => {
55
+ try {
56
+ const buffer = readFileSync(file);
57
+ resolve(buffer.length > 0 ? buffer : null);
58
+ } catch {
59
+ // No file: the operator pressed Esc, or the tool refused to capture.
60
+ if (error && error.killed !== true && error.code !== 0 && error.code !== 1) {
61
+ reject(new Error(String(error.message || error)));
62
+ return;
63
+ }
64
+ resolve(null);
65
+ } finally {
66
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
67
+ }
68
+ });
69
+ });
70
+ }
71
+
72
+ async function handleCapture(ctx, req, res) {
73
+ if (req.method !== 'POST') {
74
+ sendJson(res, 405, { ok: false, message: 'method not allowed' });
75
+ return;
76
+ }
77
+ const rejection = connectionRejection(ctx, req);
78
+ if (rejection !== undefined) {
79
+ sendJson(res, rejection, { ok: false, message: '未通过 DSH 连接鉴权(本地 token 无效)' });
80
+ return;
81
+ }
82
+ if (capturing) {
83
+ sendJson(res, 409, { ok: false, message: '已有一次截图正在进行' });
84
+ return;
85
+ }
86
+ capturing = true;
87
+ try {
88
+ const body = await readJsonBody(req);
89
+ const mode = body && body.mode === 'full' ? 'full' : 'region';
90
+ const png = await capturePng(mode);
91
+ if (png === null) {
92
+ res.writeHead(204);
93
+ res.end();
94
+ return;
95
+ }
96
+ res.writeHead(200, {
97
+ 'content-type': 'image/png',
98
+ 'content-length': String(png.length),
99
+ 'cache-control': 'no-store',
100
+ });
101
+ res.end(png);
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ ctx.logger?.warn?.('screenshot: capture failed: ' + message);
105
+ sendJson(res, 500, { ok: false, message });
106
+ } finally {
107
+ capturing = false;
108
+ }
109
+ }
110
+
111
+ function readJsonBody(req, limit = 64 * 1024) {
112
+ return new Promise((resolve, reject) => {
113
+ const chunks = [];
114
+ let size = 0;
115
+ req.on('data', (chunk) => {
116
+ size += chunk.length;
117
+ if (size > limit) {
118
+ reject(new Error('request body too large'));
119
+ req.destroy();
120
+ return;
121
+ }
122
+ chunks.push(chunk);
123
+ });
124
+ req.on('end', () => {
125
+ if (chunks.length === 0) {
126
+ resolve({});
127
+ return;
128
+ }
129
+ try {
130
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
131
+ } catch {
132
+ reject(new Error('invalid JSON body'));
133
+ }
134
+ });
135
+ req.on('error', reject);
136
+ });
137
+ }
138
+
139
+ export function apply(ctx) {
140
+ ctx.effect(() => ctx.webServer.register({
141
+ kind: 'exact',
142
+ path: CAPTURE_PATH,
143
+ handler: (req, res) => {
144
+ handleCapture(ctx, req, res).catch((error) => {
145
+ ctx.logger?.warn?.('screenshot: unhandled: ' + String(error));
146
+ try { sendJson(res, 500, { ok: false, message: String(error) }); } catch { /* already sent */ }
147
+ });
148
+ },
149
+ }));
150
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@lyra_wang/dsh-screenshot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Composer button: capture a screen region/window with macOS screencapture and drop the image straight into the DSH draft as an attachment.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "deepseek-harness",
11
+ "cordis",
12
+ "client-plugin",
13
+ "screenshot",
14
+ "capture"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "client.js",
23
+ "cordis.patch.yml",
24
+ "icon.svg",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web",
37
+ "immediately": true,
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-ui-layout"
40
+ ]
41
+ }
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/LyraWang6688/ai-capability-store.git",
46
+ "directory": "external-capabilities/plugin/dsh/screenshot"
47
+ }
48
+ }