dsh-copilot-vision 1.3.1 → 1.3.2
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/README.md +8 -2
- package/package.json +1 -1
- package/src/actions.js +38 -20
- package/src/audit.js +1 -0
- package/src/auth.js +25 -0
- package/src/hotkey.js +87 -23
- package/src/index.js +7 -1
- package/src/wait.js +17 -0
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ V1.3 起授权只有三态:`unauthorized` / `pending-persistent` / `persistent
|
|
|
18
18
|
|
|
19
19
|
- **进入授权**:按 Shift+Space 直接进入持续授权;或模型调 `vp_request_auth(mode='persistent')`,你在问答卡片上确认一次。
|
|
20
20
|
- **急停**:任何时候再按一次 Shift+Space,立即回到 `unauthorized`。这是物理热键,不经过模型、不依赖宿主 UI。
|
|
21
|
+
- **热键失效即收权(Fail-Closed)**:热键是急停的唯一通道,它一旦失效就不能继续留着持续授权。EventTap 启动失败、监听线程退出、心跳(默认每 2 秒)发现钩子不再活跃,都会立刻把状态强制收回 `unauthorized` 并写一条 `revokeAll` 审计;不会出现「授权还在、却按不动撤销」的状态。
|
|
22
|
+
- **撤销与注入竞态**:每个动作在放行时记下授权代数,真正调用 robot-ts 前再用微任务同步复核一次。人按撤销的瞬间,正在飞的这一下最多落到「已执行的移动」为止,后续注入立即中止;已经排队的其他动作一并作废。
|
|
23
|
+
- **等待可被打断**:`vp_wait_change` 轮询期间一旦授权被撤销,立刻抛「授权已被撤销,等待中断」,不会空等到超时。
|
|
21
24
|
- **未授权即拦截**:`vp_click` / `vp_click_relative` / `vp_type` / `vp_key` 在未授权时直接抛错并提示人类开启持续授权,不会自己弹卡片。
|
|
22
25
|
- **审计**:JSON Lines 记录每次授权迁移,以及每次注入的放行/拒绝(`actionAllowed` / `actionDenied`),只记动作与工具名,不记任何按键内容。
|
|
23
26
|
|
|
@@ -100,7 +103,7 @@ DSH_HOME=<DSH_HOME> dsh plugin --profile web add dsh-copilot-vision
|
|
|
100
103
|
|
|
101
104
|
# 或从本地 tarball 安装(开发调试)
|
|
102
105
|
npm pack
|
|
103
|
-
DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.3.
|
|
106
|
+
DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.3.2.tgz
|
|
104
107
|
```
|
|
105
108
|
|
|
106
109
|
- **宿主**:web(问答卡片授权)/ headless(无 UI 答者,只能 Shift+Space,需真人在场)/ desktop(待 DSH 市场审核后内置验证)。
|
|
@@ -116,6 +119,8 @@ DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot
|
|
|
116
119
|
- **聊天窗口自干扰**:宿主聊天窗口的流式重绘会被当成屏幕变化(实测覆盖率约为变化门限的 14 倍)。等页面加载时,先把聊天窗口移出主屏或最小化。
|
|
117
120
|
- **鼠标移动不可检测**:Windows 屏幕捕获不含硬件光标,挪鼠标不产生任何像素变化。
|
|
118
121
|
- **热键无法区分真人与合成按键**:按键事件流不携带注入标记,审计日志只作事后追溯、不作防伪。真正的护栏在插件之外——宿主安全分类器与操作纪律。
|
|
122
|
+
- **静默摘钩不可观测**:心跳只能读到 robot-ts 暴露的「监听线程是否存活」。Windows 若在消息循环仍在跑的情况下静默卸掉低层钩子,绑定层看不到;此时心跳无法判定失效。插件启动失败与线程退出这两条路径已按 Fail-Closed 处理。
|
|
123
|
+
- **无热键时的残余风险**:EventTap 启动即不可用时,插件不会自动收权(启动状态本就是 `unauthorized`),但人仍可通过 `vp_request_auth` 卡片确认进入 `persistent`——此时没有物理急停通道。是否放行由人在卡片上判断;本轮未禁止该路径。
|
|
119
124
|
- **索引并发**:截图索引整份覆盖写、不做跨进程并发保护,多进程共用 `VP_SHOT_DIR` 会互相覆盖;回归测试一律用独立目录。
|
|
120
125
|
- **临时目录卫生**:超过 3 天的差分/网格切片(`vp-diff-*` / `vp-grid-*`)会在插件启动时被自动清理;模型在收工时调用 `vp_summarize` 会把关键截图归档到 `archives/` 并清空其余全部碎片。基准截图(`vp-shot-*`)、审计日志、索引与 `archives/` 永久保留。
|
|
121
126
|
|
|
@@ -124,7 +129,8 @@ DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot
|
|
|
124
129
|
> 以下脚本为开发期本机验证用,含本机绝对路径,**不随仓库与 npm 包分发**(`.gitignore` 已排除 `test-*.mjs`)。克隆仓库后需自备对应脚本。
|
|
125
130
|
|
|
126
131
|
```bash
|
|
127
|
-
node test-
|
|
132
|
+
node test-hotkey.mjs # 热键 Fail-Closed:触发沿、心跳失效/抛错、启动失败、dispose 幂等(全假 Session)
|
|
133
|
+
node test-mvp.mjs # 三态授权 + 热键急停/接管 + 撤销竞态 + 等待中断 + 无时钟衰减(约 45s,真实点击屏幕中心)
|
|
128
134
|
node test-diff.mjs # 差分与索引(单元层不碰屏幕;--integration 需配合动屏)
|
|
129
135
|
node test-relative.mjs # 网格渲染 + 相对坐标换算 + 未授权闸门(不截图、不注入)
|
|
130
136
|
node test-wait.mjs # 轮询语义(--integration 配合静屏/动屏/假基线)
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from 'node:path'
|
|
|
7
7
|
import { physicalToLogical, relativeToPhysical } from './coords.js'
|
|
8
8
|
import { parseKeySpec, resolveUnicodeChar } from './keys.js'
|
|
9
9
|
import { loadRobotTs } from './robot-ts.js'
|
|
10
|
-
import { checkCanClick, getStatus } from './auth.js'
|
|
10
|
+
import { checkCanClick, getAuthEpoch, getStatus } from './auth.js'
|
|
11
11
|
import { logState } from './audit.js'
|
|
12
12
|
import { resolveShotsDir } from './paths.js'
|
|
13
13
|
import { get, nextShotId, record } from './shot-index.js'
|
|
@@ -77,9 +77,29 @@ function denyInjection(tool, gate, extra = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** 放行注入后记一笔;不改变授权状态,只留「这一次真的注入了」的证据。 */
|
|
80
|
-
function allowInjection(tool, extra = {}) {
|
|
80
|
+
function allowInjection(tool, extra = {}, grantedState = getStatus().state) {
|
|
81
|
+
logState('actionAllowed', 'tool', grantedState, getStatus().state, { tool, ...extra })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 放行快照:决策时的授权代数与状态,注入前用它做最后一次同步复核。 */
|
|
85
|
+
function captureGrant(tool) {
|
|
86
|
+
const gate = checkCanClick()
|
|
87
|
+
if (!gate.allowed) denyInjection(tool, gate)
|
|
88
|
+
return { epoch: getAuthEpoch(), state: getStatus().state }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 注入前的最后闸门。动作在放行之后还要建会话、取显示器、换算坐标,这期间人可能已经
|
|
93
|
+
* 按了 Shift+Space。先让出一个微任务,给已排队的撤销回调落地;再做同步复核。
|
|
94
|
+
* 复核通过到 native 调用之间没有 await,JS 单线程保证不会再有授权翻转插进来。
|
|
95
|
+
* 用 epoch 而不是只看 allowed:撤销后又立刻授权也算翻转,旧动作不得继续飞。
|
|
96
|
+
*/
|
|
97
|
+
async function assertGrantStillValid(tool, grant) {
|
|
98
|
+
await Promise.resolve()
|
|
99
|
+
if (checkCanClick().allowed && getAuthEpoch() === grant.epoch) return
|
|
81
100
|
const state = getStatus().state
|
|
82
|
-
logState('
|
|
101
|
+
logState('actionDenied', 'tool', state, state, { tool, reason: '授权已被撤销' })
|
|
102
|
+
throw new Error(`授权已被撤销,动作已中止(${tool})`)
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
/**
|
|
@@ -95,6 +115,7 @@ export async function clickAction(args = {}) {
|
|
|
95
115
|
denyInjection('vp_click', gate,
|
|
96
116
|
Number.isFinite(x) && Number.isFinite(y) ? { physical: { x, y } } : {})
|
|
97
117
|
}
|
|
118
|
+
const grant = { epoch: getAuthEpoch(), state: getStatus().state }
|
|
98
119
|
const { button = 'left' } = args
|
|
99
120
|
|
|
100
121
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
@@ -104,11 +125,11 @@ export async function clickAction(args = {}) {
|
|
|
104
125
|
throw new Error(`vp_click: 坐标不能为负数,收到 x=${x}, y=${y}`)
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
return await injectClick({ x, y, button, thought: args.thought, tool: 'vp_click' })
|
|
128
|
+
return await injectClick({ x, y, button, thought: args.thought, tool: 'vp_click', grant })
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
/** 真实注入的共用实现:参数校验、越界检查、换算逻辑坐标、移动并点击。 */
|
|
111
|
-
async function injectClick({ x, y, button = 'left', thought, tool }) {
|
|
132
|
+
async function injectClick({ x, y, button = 'left', thought, tool, grant }) {
|
|
112
133
|
const { Session, MouseButton } = loadRobotTs()
|
|
113
134
|
const buttons = {
|
|
114
135
|
left: MouseButton.Left,
|
|
@@ -135,9 +156,12 @@ async function injectClick({ x, y, button = 'left', thought, tool }) {
|
|
|
135
156
|
}
|
|
136
157
|
|
|
137
158
|
const logical = physicalToLogical({ x, y }, primary.scaleFactor)
|
|
159
|
+
await assertGrantStillValid(tool, grant)
|
|
138
160
|
await session.mouse.move(logical)
|
|
161
|
+
// 移动本身已是一次注入;点击前再复核一次,撤销发生在 move 期间就停在这里。
|
|
162
|
+
await assertGrantStillValid(tool, grant)
|
|
139
163
|
await session.mouse.click(buttons[button])
|
|
140
|
-
allowInjection(tool, { physical: { x, y }, button })
|
|
164
|
+
allowInjection(tool, { physical: { x, y }, button }, grant.state)
|
|
141
165
|
|
|
142
166
|
return {
|
|
143
167
|
thought: normalizeThought(thought, `点击屏幕物理坐标 (${x}, ${y})`),
|
|
@@ -158,10 +182,7 @@ async function injectClick({ x, y, button = 'left', thought, tool }) {
|
|
|
158
182
|
* V1.3 取消了键盘独立通道——单次确认卡片会把焦点抢回 DSH,注入反而落错窗口。
|
|
159
183
|
*/
|
|
160
184
|
export async function typeAction(args = {}) {
|
|
161
|
-
const
|
|
162
|
-
if (!gate.allowed) {
|
|
163
|
-
denyInjection('vp_type', gate)
|
|
164
|
-
}
|
|
185
|
+
const grant = captureGrant('vp_type')
|
|
165
186
|
|
|
166
187
|
const { text } = args
|
|
167
188
|
if (typeof text !== 'string' || text.length === 0) {
|
|
@@ -179,8 +200,9 @@ export async function typeAction(args = {}) {
|
|
|
179
200
|
}
|
|
180
201
|
// robot-ts 的 typeText 走 KEYEVENTF_UNICODE 直接注入码点,与键盘布局无关,
|
|
181
202
|
// 中文/emoji 都能原样打出来,不需要剪贴板中转。
|
|
203
|
+
await assertGrantStillValid('vp_type', grant)
|
|
182
204
|
await session.keyboard.typeText(text)
|
|
183
|
-
allowInjection('vp_type', { chars: text.length })
|
|
205
|
+
allowInjection('vp_type', { chars: text.length }, grant.state)
|
|
184
206
|
return {
|
|
185
207
|
typed: text,
|
|
186
208
|
method: 'unicode',
|
|
@@ -215,10 +237,7 @@ export function describeKeySpec(key) {
|
|
|
215
237
|
* 这样组合键语义是真的(Ctrl+V 实测能粘上),space 也能激活聚焦的按钮。
|
|
216
238
|
*/
|
|
217
239
|
export async function keyAction(args = {}) {
|
|
218
|
-
const
|
|
219
|
-
if (!gate.allowed) {
|
|
220
|
-
denyInjection('vp_key', gate)
|
|
221
|
-
}
|
|
240
|
+
const grant = captureGrant('vp_key')
|
|
222
241
|
|
|
223
242
|
const { key } = args
|
|
224
243
|
const { Session, Key, Modifier } = loadRobotTs()
|
|
@@ -236,12 +255,13 @@ export async function keyAction(args = {}) {
|
|
|
236
255
|
if (unicodeChar !== null && !session.capabilities.canTypeUnicode) {
|
|
237
256
|
throw new Error('当前宿主环境不支持 Unicode 文本注入(canTypeUnicode=false)')
|
|
238
257
|
}
|
|
258
|
+
await assertGrantStillValid('vp_key', grant)
|
|
239
259
|
if (unicodeChar !== null) {
|
|
240
260
|
await session.keyboard.typeChar(unicodeChar.codePointAt(0))
|
|
241
261
|
} else {
|
|
242
262
|
await session.keyboard.tap(spec.code, spec.modifiers)
|
|
243
263
|
}
|
|
244
|
-
allowInjection('vp_key', { key: spec.spec })
|
|
264
|
+
allowInjection('vp_key', { key: spec.spec }, grant.state)
|
|
245
265
|
return {
|
|
246
266
|
key: spec.spec,
|
|
247
267
|
keyName: spec.keyName,
|
|
@@ -288,10 +308,7 @@ export function resolveRelativeTarget({ shotId, index, xPercent, yPercent }) {
|
|
|
288
308
|
* 授权 → 参数合法性 → 越界 → 注入,所以未授权时不会读索引、不会新建 robot-ts 会话。
|
|
289
309
|
*/
|
|
290
310
|
export async function clickRelativeAction(args = {}) {
|
|
291
|
-
const
|
|
292
|
-
if (!gate.allowed) {
|
|
293
|
-
denyInjection('vp_click_relative', gate)
|
|
294
|
-
}
|
|
311
|
+
const grant = captureGrant('vp_click_relative')
|
|
295
312
|
|
|
296
313
|
const { shotId, index, xPercent, yPercent, button = 'left' } = args
|
|
297
314
|
const { bbox, physical } = resolveRelativeTarget({ shotId, index, xPercent, yPercent })
|
|
@@ -301,6 +318,7 @@ export async function clickRelativeAction(args = {}) {
|
|
|
301
318
|
button,
|
|
302
319
|
thought: args.thought,
|
|
303
320
|
tool: 'vp_click_relative',
|
|
321
|
+
grant,
|
|
304
322
|
})
|
|
305
323
|
return {
|
|
306
324
|
thought: clicked.thought,
|
package/src/audit.js
CHANGED
package/src/auth.js
CHANGED
|
@@ -28,6 +28,9 @@ const state = {
|
|
|
28
28
|
/** @type {{requestId: string, mode: 'persistent', requestedAt: number, controller: AbortController} | null} */
|
|
29
29
|
pendingRequest: null,
|
|
30
30
|
requestSeq: 0,
|
|
31
|
+
// 放行态的代数:每次 persistent 翻转 +1。动作在 checkCanClick() 放行时记下它,
|
|
32
|
+
// 注入前再比一次——即使中途「撤销又立刻授权」,旧动作也不会带着旧授权继续飞。
|
|
33
|
+
epoch: 0,
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** 当前授权状态(只读快照,不含内部的 AbortController)。 */
|
|
@@ -109,6 +112,7 @@ export function confirmPending(requestId) {
|
|
|
109
112
|
const previousState = getStatus().state
|
|
110
113
|
state.pendingRequest = null
|
|
111
114
|
state.persistent = true
|
|
115
|
+
state.epoch += 1
|
|
112
116
|
request.controller.abort({ code: 'AUTH_CANCELLED', reason: '已由人确认' })
|
|
113
117
|
audit('confirmPending', 'user-question', previousState, { mode: request.mode, requestId })
|
|
114
118
|
return { ...getStatus(), confirmed: true }
|
|
@@ -135,10 +139,30 @@ export function togglePersistent(source = 'hotkey') {
|
|
|
135
139
|
state.persistent = true
|
|
136
140
|
clearPending({ code: 'AUTH_CANCELLED', reason: '热键接管' })
|
|
137
141
|
}
|
|
142
|
+
state.epoch += 1
|
|
138
143
|
audit('togglePersistent', source, previousState)
|
|
139
144
|
return getStatus()
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
/**
|
|
148
|
+
* 系统侧的强制回收:EventTap 失效时调用,无条件回到 unauthorized。
|
|
149
|
+
* 与 togglePersistent 的区别是它只会撤销、不会开启;pending 一并清掉,
|
|
150
|
+
* 因为这个状态机已经没有可信的人类急停通道了(fail-closed)。
|
|
151
|
+
*/
|
|
152
|
+
export function revokeAll(reason = 'system') {
|
|
153
|
+
const previousState = getStatus().state
|
|
154
|
+
state.persistent = false
|
|
155
|
+
clearPending({ code: 'AUTH_CANCELLED', reason })
|
|
156
|
+
state.epoch += 1
|
|
157
|
+
audit('revokeAll', 'system', previousState, { reason })
|
|
158
|
+
return getStatus()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 当前放行态的代数(见 state.epoch 的注释)。 */
|
|
162
|
+
export function getAuthEpoch() {
|
|
163
|
+
return state.epoch
|
|
164
|
+
}
|
|
165
|
+
|
|
142
166
|
/**
|
|
143
167
|
* 鼠标与键盘共用的放行判断(不消费、不改状态)。
|
|
144
168
|
* 键盘不再有独立通道:persistent 下鼠标与键盘一律放行。
|
|
@@ -158,6 +182,7 @@ export function _resetForTests() {
|
|
|
158
182
|
state.persistent = false
|
|
159
183
|
clearPending({ code: 'AUTH_CANCELLED', reason: '测试重置' })
|
|
160
184
|
state.requestSeq = 0
|
|
185
|
+
state.epoch += 1
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
/** 测试专用:读内部标志位(getStatus 看不到 persistent 期间挂着的 pending 详情之外的东西)。 */
|
package/src/hotkey.js
CHANGED
|
@@ -1,17 +1,60 @@
|
|
|
1
1
|
// 全局热键:Shift+Space → togglePersistent()。
|
|
2
2
|
// robot-ts 没有 hotkey API,但 EventTap 能全局捕获键盘事件(非管理员可用)。
|
|
3
3
|
// 生命周期由 index.js 的 ctx.effect 负责 dispose —— 这是长驻资源,不随单次工具调用结束。
|
|
4
|
+
//
|
|
5
|
+
// Fail-Closed:热键是 persistent 唯一的人类急停通道。监听器一旦失效(线程退出、系统卸载、
|
|
6
|
+
// 会话被回收),必须立刻通知宿主把授权强制收回 unauthorized,而不是留着一个按不动的持续授权。
|
|
7
|
+
// 心跳只能看见绑定暴露的状态(isEventTapRunning);Windows 静默摘钩但消息循环仍在跑的
|
|
8
|
+
// 情况在该 API 下不可观测,见 README「已知限制」。
|
|
4
9
|
|
|
5
10
|
import { loadRobotTs } from './robot-ts.js'
|
|
6
11
|
|
|
7
|
-
export
|
|
12
|
+
export const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 2000
|
|
13
|
+
|
|
14
|
+
export async function startHotkey({
|
|
15
|
+
onToggle,
|
|
16
|
+
onUnavailable,
|
|
17
|
+
healthCheckIntervalMs = DEFAULT_HEALTH_CHECK_INTERVAL_MS,
|
|
18
|
+
// 依赖注入点:生产用真实 robot-ts,回归测试用假 Session 驱动心跳。
|
|
19
|
+
load = loadRobotTs,
|
|
20
|
+
} = {}) {
|
|
8
21
|
let session
|
|
22
|
+
let disposed = false
|
|
23
|
+
let failed = false
|
|
24
|
+
let timer
|
|
25
|
+
|
|
26
|
+
const stopHealthCheck = () => {
|
|
27
|
+
if (timer !== undefined) {
|
|
28
|
+
clearInterval(timer)
|
|
29
|
+
timer = undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const shutdown = async () => {
|
|
34
|
+
stopHealthCheck()
|
|
35
|
+
try { await session?.eventTap.stop() } catch { /* already stopped */ }
|
|
36
|
+
try { await session?.dispose() } catch { /* already disposed */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 只通知一次:心跳与 dispose 可能同时到达,重复 revokeAll 会污染审计。
|
|
40
|
+
const markUnavailable = (reason) => {
|
|
41
|
+
if (failed || disposed) return
|
|
42
|
+
failed = true
|
|
43
|
+
stopHealthCheck()
|
|
44
|
+
try {
|
|
45
|
+
onUnavailable?.(reason)
|
|
46
|
+
} catch {
|
|
47
|
+
// 回调是 fail-closed 的通知通道,它自己出错也不能让钩子线程崩。
|
|
48
|
+
}
|
|
49
|
+
void shutdown()
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
try {
|
|
10
|
-
const { Session, Key } =
|
|
53
|
+
const { Session, Key } = load()
|
|
11
54
|
session = await Session.create()
|
|
12
55
|
|
|
13
56
|
if (!session.eventTap.isSupported) {
|
|
14
|
-
await
|
|
57
|
+
await shutdown()
|
|
15
58
|
return { available: false, error: '当前环境不支持 EventTap 全局键盘捕获', dispose: async () => {} }
|
|
16
59
|
}
|
|
17
60
|
|
|
@@ -20,41 +63,62 @@ export async function startHotkey({ onToggle }) {
|
|
|
20
63
|
|
|
21
64
|
// KeyInputEvent 只有 { type, key, down },native 绑定的属性名里也没有任何注入标记,
|
|
22
65
|
// 所以这里无法过滤合成按键 —— 见 README「安全边界」。
|
|
23
|
-
|
|
24
66
|
await session.eventTap.start((event) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
try {
|
|
68
|
+
if (event.type !== 'key') return
|
|
69
|
+
if (event.key === Key.LeftShift || event.key === Key.RightShift) {
|
|
70
|
+
shiftHeld = event.down
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (event.key !== Key.Space) return
|
|
74
|
+
const wasDown = spaceHeld
|
|
75
|
+
spaceHeld = event.down
|
|
76
|
+
// 只在 Space 的按下沿触发,避免按住时的键盘重复把授权反复开关。
|
|
77
|
+
if (event.down && !wasDown && shiftHeld) {
|
|
36
78
|
onToggle()
|
|
37
|
-
} catch {
|
|
38
|
-
// 热键回调运行在原生钩子路径上,异常绝不能外抛。
|
|
39
79
|
}
|
|
80
|
+
} catch {
|
|
81
|
+
// 热键回调运行在原生钩子路径上,异常绝不能外抛。
|
|
40
82
|
}
|
|
41
83
|
})
|
|
42
84
|
|
|
85
|
+
// 心跳:定时问绑定「钩子线程还活着吗」。start 成功后再装,避免把启动期误判成失效。
|
|
86
|
+
// healthCheckIntervalMs <= 0 表示关闭(保留给需要零后台定时的宿主)。
|
|
87
|
+
if (healthCheckIntervalMs > 0) {
|
|
88
|
+
timer = setInterval(() => {
|
|
89
|
+
let running
|
|
90
|
+
try {
|
|
91
|
+
running = session.eventTap.isRunning
|
|
92
|
+
} catch (error) {
|
|
93
|
+
markUnavailable(
|
|
94
|
+
`EventTap 心跳检查失败:${error instanceof Error ? error.message : String(error)},已强制撤销全部授权`,
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (!running) {
|
|
99
|
+
markUnavailable('EventTap 已停止:全局钩子失效,已强制撤销全部授权')
|
|
100
|
+
}
|
|
101
|
+
}, healthCheckIntervalMs)
|
|
102
|
+
timer.unref?.()
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
return {
|
|
44
106
|
available: true,
|
|
45
107
|
error: undefined,
|
|
108
|
+
healthCheckIntervalMs,
|
|
46
109
|
dispose: async () => {
|
|
47
|
-
|
|
48
|
-
|
|
110
|
+
if (disposed) return
|
|
111
|
+
disposed = true
|
|
112
|
+
await shutdown()
|
|
49
113
|
},
|
|
50
114
|
}
|
|
51
115
|
} catch (error) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
116
|
+
await shutdown()
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
118
|
+
markUnavailable(`EventTap 启动失败:${message},已强制撤销全部授权`)
|
|
55
119
|
return {
|
|
56
120
|
available: false,
|
|
57
|
-
error:
|
|
121
|
+
error: message,
|
|
58
122
|
dispose: async () => {},
|
|
59
123
|
}
|
|
60
124
|
}
|
package/src/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
12
12
|
import { createPlugin } from './plugin.js'
|
|
13
13
|
import { startHotkey } from './hotkey.js'
|
|
14
|
-
import { togglePersistent } from './auth.js'
|
|
14
|
+
import { togglePersistent, revokeAll } from './auth.js'
|
|
15
15
|
import { cleanupVisualFragments } from './paths.js'
|
|
16
16
|
|
|
17
17
|
export const name = 'dsh-copilot-vision'
|
|
@@ -30,6 +30,12 @@ export function apply(ctx) {
|
|
|
30
30
|
onToggle: () => {
|
|
31
31
|
togglePersistent()
|
|
32
32
|
},
|
|
33
|
+
// 急停通道失效 = 不能继续相信 persistent:先摘掉热键可用标记,再强制回 unauthorized。
|
|
34
|
+
onUnavailable: (reason) => {
|
|
35
|
+
hotkeyState.available = false
|
|
36
|
+
hotkeyState.error = reason
|
|
37
|
+
revokeAll('eventtap')
|
|
38
|
+
},
|
|
33
39
|
}).then((started) => {
|
|
34
40
|
hotkeyState.available = started.available
|
|
35
41
|
hotkeyState.error = started.error
|
package/src/wait.js
CHANGED
|
@@ -14,6 +14,7 @@ import { PNG } from 'pngjs'
|
|
|
14
14
|
import pixelmatch from 'pixelmatch'
|
|
15
15
|
import { loadRobotTs } from './robot-ts.js'
|
|
16
16
|
import { get, assertShotFileExists } from './shot-index.js'
|
|
17
|
+
import { checkCanClick, getAuthEpoch } from './auth.js'
|
|
17
18
|
import { screenshotAction } from './actions.js'
|
|
18
19
|
import { diffPair, connectedComponents, DEFAULT_MIN_REGION_AREA } from './diff.js'
|
|
19
20
|
|
|
@@ -75,6 +76,19 @@ export async function waitChangeAction({
|
|
|
75
76
|
assertShotFileExists(baseline, baselineMeta, 'vp_wait_change')
|
|
76
77
|
const baselineImg = readPng(baselineMeta.path)
|
|
77
78
|
|
|
79
|
+
// 等待本身不要求授权;只有「带着持久授权开始等」的调用会被撤销打断。
|
|
80
|
+
// 记在会话创建之前,这样建会话期间发生的撤销在第一次轮询时就能发现。
|
|
81
|
+
const startedAllowed = checkCanClick().allowed
|
|
82
|
+
const startedEpoch = getAuthEpoch()
|
|
83
|
+
const assertWaitGrant = async () => {
|
|
84
|
+
if (!startedAllowed) return
|
|
85
|
+
// 让排队的撤销回调先落地,再同步复核;epoch 变化(含撤销后立刻重授权)一律中断。
|
|
86
|
+
await Promise.resolve()
|
|
87
|
+
if (!checkCanClick().allowed || getAuthEpoch() !== startedEpoch) {
|
|
88
|
+
throw new Error('授权已被撤销,等待中断')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
const { Session } = loadRobotTs()
|
|
79
93
|
const session = await Session.create()
|
|
80
94
|
const startedAt = Date.now()
|
|
@@ -106,6 +120,7 @@ export async function waitChangeAction({
|
|
|
106
120
|
const mask = new Uint8Array(pixels)
|
|
107
121
|
|
|
108
122
|
for (;;) {
|
|
123
|
+
await assertWaitGrant()
|
|
109
124
|
const tickStartedAt = Date.now()
|
|
110
125
|
const frame = await session.screen.captureMonitor(primary.id)
|
|
111
126
|
const current = frame.toBuffer()
|
|
@@ -123,6 +138,8 @@ export async function waitChangeAction({
|
|
|
123
138
|
}
|
|
124
139
|
// 已经开始的这一帧一定跑完,所以超时路径上的 waitedMs 必然 >= timeoutMs。
|
|
125
140
|
if (waitedMs >= timeoutMs) break
|
|
141
|
+
// 睡之前再查一次:把「撤销 → 醒来才发现」的延迟压到一帧以内。
|
|
142
|
+
await assertWaitGrant()
|
|
126
143
|
await sleep(Math.max(0, pollIntervalMs - (Date.now() - tickStartedAt)))
|
|
127
144
|
}
|
|
128
145
|
} finally {
|