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 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.1.tgz
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-mvp.mjs # 三态授权 + 热键急停/接管 + 无时钟衰减(约 45s,真实点击屏幕中心)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-copilot-vision",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Computer Use enhancement plugin for DeepSeek Harness (DSH). 纯 JS 视觉差分、网格位置编码、键盘注入、语义级记忆压缩、长程任务闭环与人机共驾授权。",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
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('actionAllowed', 'tool', state, state, { tool, ...extra })
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 gate = checkCanClick()
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 gate = checkCanClick()
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 gate = checkCanClick()
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
@@ -14,6 +14,7 @@ export const AUDIT_ACTIONS = [
14
14
  'confirmPending',
15
15
  'cancelPending',
16
16
  'togglePersistent',
17
+ 'revokeAll',
17
18
  'actionAllowed',
18
19
  'actionDenied',
19
20
  ]
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 async function startHotkey({ onToggle }) {
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 } = loadRobotTs()
53
+ const { Session, Key } = load()
11
54
  session = await Session.create()
12
55
 
13
56
  if (!session.eventTap.isSupported) {
14
- await session.dispose()
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
- if (event.type !== 'key') return
26
- if (event.key === Key.LeftShift || event.key === Key.RightShift) {
27
- shiftHeld = event.down
28
- return
29
- }
30
- if (event.key !== Key.Space) return
31
- const wasDown = spaceHeld
32
- spaceHeld = event.down
33
- // 只在 Space 的按下沿触发,避免按住时的键盘重复把授权反复开关。
34
- if (event.down && !wasDown && shiftHeld) {
35
- try {
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
- try { await session.eventTap.stop() } catch { /* already stopped */ }
48
- try { await session.dispose() } catch { /* already disposed */ }
110
+ if (disposed) return
111
+ disposed = true
112
+ await shutdown()
49
113
  },
50
114
  }
51
115
  } catch (error) {
52
- if (session !== undefined) {
53
- try { await session.dispose() } catch { /* already disposed */ }
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: error instanceof Error ? error.message : String(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 {