dsh-longpress-shot
Long-press the left mouse button anywhere in DSH, drag a box, and the region lands on disk as a PNG — with a preview card that drops the image straight into the composer.
dsh-longpress-shot adds region capture to DeepSeek Harness without a hotkey, a tray menu, or a separate app. Hold the left button for half a second, the page dims and turns into a crosshair, drag the area you want, release. The crop is written to ~/.dsh/longpress-shots/ and the same bytes go into the system clipboard.
Windows only (the capture path is GDI through PowerShell).
Overlay armed after a 550 ms hold
Install
dsh plugin add dsh-longpress-shot
Or install straight from the release tarball, which needs no registry:
dsh plugin add "https://github.com/kkkkk-tubu/dsh-longpress-shot/releases/download/v1.11.2/dsh-longpress-shot-1.11.2.tgz"
Then restart DSH. Nothing appears in the navigation bar — the plugin works everywhere, on any page, with no entry point to find.
Manual install: put the package under <profile>/node_modules/, add it to the profile's dependencies, restart.
Usage
| Action | Result |
|---|
| Hold left button ≥ 550 ms | Page dims, cursor turns into a crosshair |
| Keep holding, then drag | Blue selection box follows the pointer; release captures that region |
| Hold and release without dragging | Stays armed — press again to redraw the box (never silently captures the whole window) |
Double-click or Enter while armed | Capture the whole DSH window |
Right-click or Esc | Cancel |
The crop is saved as ~/.dsh/longpress-shots/shot-YYYYMMDD-HHMMSS-mmm.png, copied to the system clipboard, and shown in a preview card with 发到输入框 (send to composer) / 另存为 (save as) / 复制路径 (copy path) / 关闭 (close).
To get the image into a conversation, use the card button, press Ctrl+V in the composer, or drag the thumbnail onto it — all three end up in the same attachment intake.
Drag the card by its header to move it; the position is remembered in localStorage. The default position avoids the whale widget in the bottom-right corner.
The sections below are the full engineering notes: how the capture path works, why the drop path falls back to a synthetic paste, how to run the self-tests, and how to roll back.
1. 交互细节(中文工程笔记)
Dragging a selection box
| 操作 | 结果 |
|---|
| 左键按住不动 ≥ 550ms | 出现半透明遮罩,鼠标变十字 |
| 长按后不松手继续拖 | 实时画蓝色选框,松手 → 抓该区域 |
| 长按后直接松手 | 停在遮罩里,可再按下重新拖框(不会误抓整窗) |
遮罩里双击 / 按 Enter | 抓整个 DSH 窗口 |
右键 / 按 Esc | 取消,什么都不抓 |
抓完弹预览卡:缩略图 + 像素尺寸 + 文件名,四个按钮 发到输入框 / 另存为 / 复制路径 / 关闭。
图片同时写进系统剪贴板,Ctrl+V 也可直接贴进对话框。
Preview card with the crop, its size and filename
把截图送进对话框,有三种方式(按可靠度排序):
| 方式 | 操作 | 说明 |
|---|
| 按钮(推荐) | 点卡片上的 发到输入框 | 直接把图作为附件插进输入框,立刻出现附件缩略图 |
| 粘贴 | 点一下输入框,Ctrl+V | 抓图时已写入系统剪贴板 |
| 拖拽 | 按住卡片缩略图拖到输入框 | 能拖就直连;Chromium 吞掉 Files 时由 drop 兜底改走粘贴通道 |
预览卡怎么摆弄:
| 操作 | 结果 |
|---|
| 按住卡片标题栏拖 | 卡片跟着鼠标走,松手后位置写进 localStorage,下次开在同一处 |
| 拖出窗口 / 拖到边缘 | 夹在视口内,留 8px 边距,不会被拖丢 |
| 点图片区或按钮区 | 不触发拖动(只有 header 是把手) |
点 × 或 关闭 | 关卡片;点在卡片上的任何事件都不会漏给桌宠 |
默认位置:视口右下角往左上偏移,避开鲸鱼挂件的矩形(右下角那只会说话的鲸鱼)。
鲸鱼位置从 .dshwv-root 的 getBoundingClientRect() 现算;找不到就退回右下角。
不触发的场景:input / textarea / select / contenteditable 内长按(不抢文本选择)。
把缩略图拖进输入框,附件缩略图立刻出现在 composer 里:
Image dragged into the composer as an attachment
2. 目录结构
dsh-longpress-shot/
├── package.json # dsh.bundle.patch → cordis.patch.yml
├── cordis.patch.yml # 插件注册补丁
├── lib/
│ ├── index.js # 宿主半区:注入行 + 四条路由 + 调 PowerShell
│ ├── shot.js # 浏览器半区:长按检测、遮罩框选、预览卡
│ └── capture.ps1 # GDI 抓窗口客户区 + 按比例裁剪 + 写剪贴板
├── screenshots/ # README 用图(真机截的,1100×706)
├── screenshots.json # 图片清单,市场页读它
├── selftest-drag.mjs # 离线自测:假 DOM 跑完整交互状态机(9 组,全 PASS)
├── selftest-row.mjs # 离线自测:注入行 DESKTOP_ROW_TEXT 的解析与行为(18 项,全 PASS)
├── selftest-card.mjs # 离线自测:预览卡拖动 / 默认位置 / 事件隔离 / 送进对话框(42 项,全 PASS)
├── selftest-dragout.mjs # 离线自测:拖缩略图出卡片 + drop 兜底(32 项,全 PASS)
├── mouse-sim.ps1 # 真机鼠标模拟:longpress / drag / click,物理像素
├── validate-profile.mjs # 校验 profile 注册是否完整
└── sync-to-profile.sh # 源码 → profile 同步(见第 4 节)
3. 工作原理
页面 pointerdown ──550ms──▶ shot.js 建遮罩(armed 继承按住这一笔)
│ │
│ pointermove → paint() 画选框
│ │
└──── pointerup ─────────┴─▶ POST /dsh-longpress-shot/capture
{x,y,w,h,aspect} 比例,非像素
│
lib/index.js 夹紧到 [0,1]
│
spawn powershell capture.ps1
│
EnumWindows 找 DSH 窗口 → 客户区坐标
CopyFromScreen → Clone(裁剪矩形) → Save PNG
│
{ok, file, width, height, clientW, ...}
│
shot.js 弹预览卡 + 写剪贴板
│
┌──── 缩略图拖到输入框 ────┐ └── 点「发到输入框」──┐
dragstart 塞 File 宿主 drop 拿不到 Files 合成 ClipboardEvent('paste')
│ │(Chromium 同页 DOM 拖拽 clipboardData.items=[File]
└───────────────────────▶│ 不暴露 File) │
└──▶ pasteIntoComposer() ◀─┘
│
宿主 Lexical PASTE_COMMAND 读 items
│
handlers.intakeFiles(files) → 附件缩略图
为什么抓屏在宿主做:页面拿不到 OS 级截图(getDisplayMedia 会弹选择器)。
宿主插件跑在 Node 里,可以直接起 PowerShell 用 GDI 抓窗口客户区。
为什么注入用内联 script 而不是 script-src 行:桌面端 index.html 是安装包静态直出,
tapIndex 变换不生效;而 script-src 行加载失败会 reject 掉整个 __DSH_BOOT_READY__,
把 DSH 界面弄瞎。内联脚本自己 onerror 吞掉,路由不在时静默失败。
为什么选区是比例不是像素:capture.ps1 先 SetProcessDpiAwarenessContext(-4)(per-monitor v2),
之后全是物理像素;前端只知道自己 CSS 像素。传比例,两端各自换算,缩放/DPI 都能对上。
为什么送进对话框走粘贴而不是拖放:同页面 DOM 拖拽时,Chromium 只把 text/* 与
chromium/x-drag-id 交给 target,DataTransferItem 里的 File 到 dragenter 就没了
(dragstart 里 types='Files'、drop 里 types='text/plain|chromium/x-drag-id',真机 diag 逐条对上)。
宿主 installDocumentDropEvents 判 dt.types.includes("Files"),恒假即放弃。
而 PASTE_COMMAND 只读 event.clipboardData.items,不经过 OS 剪贴板 —— 合成一个
ClipboardEvent 就能直接喂给 intakeFiles。这条是唯一稳的路,别改回拖放。
4. 改完代码怎么让它生效
关键:宿主读的是 profile 里那份 lib/shot.js,不是 workspace 里这份。
两者原本是硬链接,但 edit/write 是「写临时文件再改名」,inode 会换,链接就断了。
所以每次改完源码跑:
cd "C:/Users/19906/Documents/deepseek-harness/default-workspace/dsh-longpress-shot"
bash sync-to-profile.sh
脚本会判断是否已是同一 inode,是就跳过,不是就复制。
| 改了什么 | 怎么生效 |
|---|
lib/shot.js | sync-to-profile.sh 后不用手动刷新:loader 每 5s 轮询 /dsh-longpress-shot/version,发现磁盘版本 ≠ 页面版本就自己 location.reload();最多等 5–8 秒 |
lib/capture.ps1 | 同上,每次抓屏都是新起进程 |
lib/index.js / package.json / cordis.patch.yml | sync-to-profile.sh 后必须重启 DSH(注入行和路由是启动时注册的) |
打包版没有 Ctrl+R
E:/DSH/resources/app/lib/main.js 里 role: "reload" 的菜单项包在 development 分支里,
打包版根本没绑 Ctrl+R。所以让新 shot.js 进页面的手段只有两条:
- 自动(默认走这条):loader 每 5s 轮询版本,改完
bash sync-to-profile.sh 等 5–8 秒自己刷。
- 手动:
F12 打开 DevTools(toggleDevTools 没被 development 门控,一定可用),
在 DevTools 窗口里按 Ctrl+R;或托盘右键 → 退出,重开 DSH。
两种生效等级,别混
| 目标 | 需要什么 | 为什么 |
|---|
新 shot.js 进页面 | 刷新即可 | GET /dsh-longpress-shot/shot.js 按 mtime 重读 + no-store,每次请求都读磁盘 |
/version 路由 + loader 轮询 | 重启一次 DSH | 这两样是 index.js 的 apply() 里注册的,只在宿主启动时执行一次 |
所以「框选修复」单靠刷新就能生效;「版本自愈 loader」必须重启一次才能装进注入表。
判断当前进程有没有 loader:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:63342/dsh-longpress-shot/version
# 200 = 有;404 = 进程还在用旧 index.js,重启一次
验证当前路由喂的是新版:
curl -s http://127.0.0.1:63342/dsh-longpress-shot/version
# {"ok":true,"version":"1.8.0","bytes":29799}
curl -s http://127.0.0.1:63342/dsh-longpress-shot/shot.js | grep -c "version: '1.8.0'"
# 1
端口从运行时上下文拿(当前是 63342)。
5. 自测
cd "C:/Users/19906/Documents/deepseek-harness/default-workspace/dsh-longpress-shot"
ELECTRON_RUN_AS_NODE=1 "E:/DSH/DeepSeek Harness.exe" --check lib/shot.js # 语法
ELECTRON_RUN_AS_NODE=1 "E:/DSH/DeepSeek Harness.exe" selftest-drag.mjs # 交互状态机
ELECTRON_RUN_AS_NODE=1 "E:/DSH/DeepSeek Harness.exe" selftest-row.mjs # 注入行本身
ELECTRON_RUN_AS_NODE=1 "E:/DSH/DeepSeek Harness.exe" selftest-card.mjs # 预览卡
真机鼠标模拟(会真的动你的鼠标,物理像素):
# 长按 (1000,400) 0.9s 后拖到 (1400,700) 松手 → 抓框
powershell -ExecutionPolicy Bypass -NoProfile -File mouse-sim.ps1 `
-Action longpress -X1 1000 -Y1 400 -X2 1400 -Y2 700
# 在 (X1,Y1) 按住拖到 (X2,Y2) → 测卡片拖动
powershell -ExecutionPolicy Bypass -NoProfile -File mouse-sim.ps1 `
-Action drag -X1 1959 -Y1 305 -X2 1559 -Y2 355
# 单击
powershell -ExecutionPolicy Bypass -NoProfile -File mouse-sim.ps1 -Action click -X1 1835 -Y1 1147
mouse-sim.ps1 只选 DeepSeek Harness 那个有标题的窗口做前台。
注意系统禁用了脚本执行时要用 powershell -ExecutionPolicy Bypass -File(不能用 & .\x.ps1)。
这台机器 PATH 里没有 node/npm/npx,所以要用 Electron 自带的 Node
(ELECTRON_RUN_AS_NODE=1 + exe 路径)来跑 .mjs。
selftest-drag.mjs 覆盖 9 组:
- 长按 600ms 触发遮罩
- 不松手继续拖 → 选框尺寸正确
- 松手 → 按选区提交(不再是整窗)
- 长按后直接松手 → 不抓图,留在遮罩
- 遮罩里重新按下拖框 → 正常出选区
Esc → 取消不抓图
input 上长按不触发
- 遮罩里双击 → 整窗;第一次按下不能误判成双击
Enter → 整窗
selftest-card.mjs 覆盖 42 项,专测预览卡:
- 卡片创建
- 默认位置不与鲸鱼矩形相交、在视口内、走
left/top 内联定位、right/bottom 清成 auto
- header 拖动生效(位移量精确匹配)、图片区按下不拖动、位置写
localStorage、松手清 _lps-dragging
× 的 click 在 window 捕获层被 stopImmediatePropagation 截断、真的关掉卡片
- 拖完松手浏览器补发的那个
click 不触发按钮
- 卡片外的
click / pointerdown 不受影响(不误伤页面)
- 再抓一次,卡片落在
localStorage 记的位置
- 拖源是
div._lps-drag 不是 img;dataTransfer 里有 1 个真 File、名字是
shot-*.png、没有 DownloadURL、没有 text/uri-list
[10] 点 发到输入框 → 合成 ClipboardEvent 把文件递给 composer(假 composer 读
items[i].getAsFile(),断言名字与 MIME)
[11] 复现真机现象:dragover 时 types 里没有 Files,宿主放弃 → 我们的
drop 兜底自动改走粘贴;dragend 不重复插入
[12] 拖到非输入框区域不误插
为什么必须挂在 window 捕获而不是 document:鲸鱼挂件(dsh-whale-widget)的
onDocClickStopper / onDocPointerDown 挂在 document 捕获,判定用的是
isWhaleHit() 的几何命中(拿 img 的 rect + 透明像素画布算,完全不看 e.target)。
卡片压在鲸鱼身上时它照样认为「点在鲸鱼上」→ 吞掉 click,表现就是「点关闭结果点到桌宠」。
捕获路径 window → document → … → target,我们必须在 window 上先跑。
为什么拖动结束要设 dragSuppressOnce:拖拽松手后浏览器会补发一次 click,
它的 target 是拖动终点下面的元素。若终点压在 × 上就会误关卡片。
真实点击一定从 pointerdown 开始,所以 pointerdown 时清标记、补发的 click 吃掉。
selftest-row.mjs 覆盖 18 项,专治「宿主全 200、页面完全没反应」这一类:
括号/可解析、SCRIPT_URL/VERSION_URL 拼接、() {} 计数平衡,再在 node:vm
假环境里真跑一遍 IIFE,逐项断言真的挂了 <script>、真带了 ?v=、真注册了 5s 轮询、
真发了一次 probe、window 上真设了 __DSH_LONGPRESS_LOADER__、第二次执行不再挂。
这些断言必须逐项做:注入行整段包在 try{}catch(e){} 里,任何 ReferenceError
都会被静默吞掉,「执行不抛异常」本身完全不构成证据。
后端裁剪链路的单独验证(绕开前端):
curl -s -X POST http://127.0.0.1:51916/dsh-longpress-shot/capture \
-H "Content-Type: application/json" \
--data-binary '{"x":0.25,"y":0.20,"w":0.40,"h":0.35,"aspect":1.6,"clipboard":false}'
期望 width/height 约为 clientW*0.40 × clientH*0.35(不是整窗)。端口取
C:/Users/19906/.dsh/dsh-config-manager/running/desktop.json 里的 port,重启会变。
6. 排错
| 现象 | 原因 / 处理 |
|---|
| 长按没反应 | 页面没刷新;或焦点在输入框里;curl .../shot.js 看路由是否 200 |
| 截图尺寸比屏幕上小一圈(约 67%) | capture.ps1 的 DPI 感知没生效 → PowerShell 以「虚拟化」跑,GetClientRect 返回 ÷1.5 的尺寸。查两处:SetProcessDpiAwarenessContext 声明返回 bool(不是 IntPtr)、文件带 UTF-8 BOM。selftest-drag.mjs 会打印 clientW/clientH |
改了 capture.ps1 但行为没变 | BOM 被编辑器/同步脚本剥掉了。xxd -l 3 lib/capture.ps1 必须是 efbbbf |
| 蓝框几乎看不见 | v1.7.0 前蓝框没有 z-index,被遮罩压暗后与背景只差约 18% 亮度。升级 shot.js 即可 |
长按完全没反应,但三条路由全 200、/version 也正常 | 注入行 DESKTOP_ROW_TEXT 解析失败,整段被浏览器丢弃。跑 selftest-row.mjs 定位;2026-10-26 就是这么挂的(多一个 }) |
| 一直是整窗截图 | 用的是 v1.0.0 的 shot.js(没有 armed 逻辑)。跑 sync-to-profile.sh,等 5–8 秒自动刷新(或 F12 → DevTools 里 Ctrl+R) |
Ctrl+R 按了没用 | 打包版没绑这个键(main.js 里 reload 菜单项在 development 分支)。改用版本自愈轮询、F12 → DevTools 内 Ctrl+R、或退出重开 |
curl .../version 返回 404 | 进程还在跑旧 index.js,/version 路由没注册。重启一次 DSH |
| 改完 shot.js 一直不自动刷新 | /version 404(同上),或页面开了多个窗口只有当前窗口有 loader |
抓屏失败:window-not-found | EnumWindows 没匹配到客户区 ≥200×200 的 DSH 窗口,检查窗口是否最小化 |
抓屏失败:empty-region | 选框太小(<8px)或比例算出来 <1px |
抓屏失败:timeout | PowerShell 15s 没回,通常是被杀软拦了 |
| 提示「窗口宽高比与页面不一致」 | aspectMismatch:抓到的客户区宽高比和页面视口差 >8%,说明抓错了窗口 |
| 预览卡拖不动 | 只有 header 是把手;按在图片或按钮上不拖。selftest-card.mjs 第 3 组能定位 |
| 点关闭结果点了桌宠 | 事件隔离没挂到 window 捕获。鲸鱼用 document 捕获 + 几何命中,window 上才抢先。selftest-card.mjs 第 4 组 |
| 拖完松手卡片自己关了 | dragSuppressOnce 逻辑错:补发的 click 被当成真点击。selftest-card.mjs 第 4b 组 |
排查顺序(自上而下,每步都能独立证伪一环):
curl -s http://127.0.0.1:<port>/dsh-longpress-shot/version —— 404 说明 index.js 没加载,先去重启,别往下查。
ELECTRON_RUN_AS_NODE=1 ... selftest-row.mjs —— 注入行本身有没有解析错。
curl -s .../shot.js | wc -c —— 路由给的是不是当前版本(v1.11.2 = 44099 字节)。
- 页面里
window.__DSH_LONGPRESS_LOADER__ / window.__DSH_LONGPRESS_SHOT__ —— 前者 1 说明注入行跑了,后者存在说明 shot.js 加载成功。两个都没有但 1、2、3 都正常 → 注入表没进这个窗口(桌面端注入表在宿主启动时一次性收集,重开一次窗口即可)。
7. 回退
只想关掉但保留文件:从 profile 的 package.json 里删掉 dsh-longpress-shot 依赖,
或用插件管理页停用它,然后重启 DSH。
完全卸载:
cd "C:/Users/19906/.dsh/profiles/desktop"
# 1) 从 package.json 的 dependencies 里删掉 "dsh-longpress-shot"
# 2) 删包
rm -rf node_modules/dsh-longpress-shot
# 3) 重启 DSH
代码改坏了想退回:改的是 workspace 里那份,profile 那份是副本 —— 直接
git checkout(如果 workspace 在版本控制下)或从备份恢复,再跑 sync-to-profile.sh。
反过来,如果 profile 那份坏了而 workspace 是好的,跑一次 sync-to-profile.sh 就修回来。
注意:profile package.json 里那行 dsh-longpress-shot 依赖一旦删掉,
下次 pnpm install 就会把包清掉;只删 node_modules 里的目录而不删依赖行,pnpm install 会重新装回来。
8. 安全边界
三条路由都过 rejected():
- Host 必须是回环地址(
localhost / 127.x.x.x / ::1),挡 DNS 重绑定
Sec-Fetch-Site: cross-site 一律 403
- 有
Origin 时其 host 必须与 Host 一致
/file 只接受正则 ^shot-[0-9]{8}-[0-9]{6}-[0-9]{3}\.png$ 且 basename(name) === name,挡路径穿越。
capture.ps1 只抓本进程祖先链上的窗口(从 -AncestorPid 向上最多 10 层),不是全屏任取。
9. 版本
-
v1.11.2(当前)— 修 v1.11.0 遗留的「有的拖法还是没反应」:
现象:v1.11.0 上线后,点 发到输入框 一定成功,但用手指拖有时成功、有时什么都不发生。
查 diag.jsonl 看到关键一列:dragover 里 over=False,而落点明明在输入框上。
根因在 inComposer():
// 旧(错)
return ed === t || ed.contains(t) || t.closest('.cm-content,...')
drop / dragover 的 ev.target 是 composer 的外层容器
(实测依次是 DIV.an3WzG_composerStack → DIV.GRgSLq_card → DIV.GRgSLq_input
→ DIV.GRgSLq_row → BUTTON.GRgSLq_add),这些是 .cm-content 的祖先,
所以 ed.contains(t) 恒假、t.closest('.cm-content') 也恒 null
→ overComposer 永远是 false → rescue() 第一道判据就 return
→ 拖拽不插文件。反过来只要指针恰好扫到编辑器本体(.cm-content),就会成功——
这就是「时好时坏」的来源。
改动(lib/shot.js):
inComposer(t, ev) 三个方向都认:ed.contains(t) / t.contains(ed) /
t.closest('.cm-content,[data-lexical-editor="true"],.cm-editor')。
- 加几何兜底:指针
clientX/clientY 落在编辑区矩形外扩一圈
(左右 40px、上 70px、下 30px,覆盖工具条与内边距)内也算命中。
dragenter 也参与粘住 overComposer(原来只有 dragover 更新它),
并用 overComposer || inComposer(...) 累积而不是覆盖——拖拽途中划出去一次不会清零。
rescue() 判据改成 overComposer || inComposer(ev.target, ev),
dragend 的 ev.target 是拖源自己(DIV._lps-drag),靠坐标兜底也能命中。
diag 增列:dragenter.over、drop-rescue.over / near。
真机验收(2026-10-28 18:35,客户区 1919×1232 origin (320,185)):
拖拽路径 (1160,930) → (760,1100),diag 序列为
dragenter ... over=True → drop(types 无 Files)→
paste-inject {"consumed":true,"dispatched":false} →
drop-rescue {"ok":true,"over":true,"near":true} → 输入框里出现附件缩略图 + 光标。
全程不再弹任何提示(v1.11.1 已把兜底提示删掉)。
自测:selftest-card.mjs 42 项 / selftest-drag.mjs / selftest-dragout.mjs 32 项 /
selftest-row.mjs 18 项 全 PASS。