dsh-copilot-vision 1.1.0 → 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/src/auth.js CHANGED
@@ -1,23 +1,23 @@
1
1
  // Vision-Pilot 授权状态机(宿主无关,纯内存单例)。
2
2
  //
3
- // 原则:授权状态只由人的明确动作改变,不由时钟改变。
4
- // 因此本文件没有任何到期判断,也不返回 remainingMs —— persistent 一经进入就一直有效,
5
- // 直到用户再次按 Shift+Space 撤销。
3
+ // 授权模型(V1.3):只有两种权限结论 + 一种等待态。
4
+ // unauthorized 未授权,一切鼠标与键盘操作直接拦截
5
+ // pending-persistent 已请求持续授权、等待人类在卡片上确认
6
+ // persistent 持续授权中,鼠标与键盘一律直接放行
6
7
  //
7
- // 五种 state:
8
- // unauthorized 未授权
9
- // pending-once 已请求单次授权、等待人类确认(带 pendingRequest)
10
- // authorized-once 已确认、等待下一次点击消费(无 pendingRequest)
11
- // pending-persistent 已请求持续授权、等待人类确认
12
- // persistent 持续授权中,无到期
8
+ // 铁律:授权只由人的明确动作改变——按 Shift+Space,或在卡片上确认 persistent 请求。
9
+ // 不由时钟改变:没有任何到期判断,也不返回 remainingMs。
13
10
  //
14
- // 分工:checkCanClick() 只看是否放行、不改变状态;consumeOnce() 才真正消费
15
- // authorized-once。因此坐标越界等失败路径不会浪费一次单次授权。
11
+ // V1.3 取消了两样东西,别再引回来:
12
+ // - 单次授权(pending-once / authorized-once / consumeOnce):点「允许」会把焦点抢回
13
+ // DSH 窗口,紧接着的注入落进错误窗口,是实测确认的故障来源。
14
+ // - 键盘独立通道(keyboardGranted / checkCanKeyboard / consumeKeyboard):键盘与鼠标
15
+ // 共用 checkCanClick(),persistent 下一律放行,unauthorized 下一律拦截。
16
16
  //
17
- // 每次动作(含被拒绝的点击)都会追加一条审计记录,见 src/audit.js。
17
+ // 每次授权动作与每次被拒绝/放行的注入都追加审计记录,见 src/audit.js。
18
18
  //
19
- // 竞态约定(P0 修复):pendingRequest 的生命周期绑一个 AbortController。任何清空 pending 的
20
- // 动作(热键接管/撤销、cancelPending)都必须 abort 它,让调用方那侧「还在等人点卡片」的
19
+ // 竞态约定:pendingRequest 的生命周期绑一个 AbortController。任何清空 pending 的动作
20
+ // (热键接管/撤销、cancelPending)都必须 abort 它,让调用方那侧「还在等人点卡片」的
21
21
  // promise 立刻收到 AUTH_CANCELLED 并正常返回,而不是等那个答案迟到后撞进 confirmPending()。
22
22
  // confirmPending 只认自己那一次的 requestId:对不上就是迟到的答案,安全丢弃。
23
23
 
@@ -25,26 +25,32 @@ import { logState } from './audit.js'
25
25
 
26
26
  const state = {
27
27
  persistent: false,
28
- onceGranted: false,
29
- /** @type {{requestId: string, mode: 'once'|'persistent', requestedAt: number, controller: AbortController} | null} */
28
+ /** @type {{requestId: string, mode: 'persistent', requestedAt: number, controller: AbortController} | null} */
30
29
  pendingRequest: null,
31
30
  requestSeq: 0,
31
+ // 放行态的代数:每次 persistent 翻转 +1。动作在 checkCanClick() 放行时记下它,
32
+ // 注入前再比一次——即使中途「撤销又立刻授权」,旧动作也不会带着旧授权继续飞。
33
+ epoch: 0,
32
34
  }
33
35
 
34
36
  /** 当前授权状态(只读快照,不含内部的 AbortController)。 */
35
37
  export function getStatus() {
38
+ // 待确认请求独立于 state 展示:persistent 在位时也可能挂着一张卡片,
39
+ // 藏起来的话人/model 都看不到「还有一张卡在等」。
40
+ const pending = state.pendingRequest === null
41
+ ? {}
42
+ : {
43
+ pendingRequest: {
44
+ requestId: state.pendingRequest.requestId,
45
+ mode: state.pendingRequest.mode,
46
+ requestedAt: state.pendingRequest.requestedAt,
47
+ },
48
+ }
36
49
  if (state.persistent) {
37
- return { state: 'persistent' }
50
+ return { state: 'persistent', ...pending }
38
51
  }
39
52
  if (state.pendingRequest !== null) {
40
- const { requestId, mode, requestedAt } = state.pendingRequest
41
- return {
42
- state: mode === 'once' ? 'pending-once' : 'pending-persistent',
43
- pendingRequest: { requestId, mode, requestedAt },
44
- }
45
- }
46
- if (state.onceGranted) {
47
- return { state: 'authorized-once' }
53
+ return { state: 'pending-persistent', ...pending }
48
54
  }
49
55
  return { state: 'unauthorized' }
50
56
  }
@@ -65,14 +71,11 @@ function clearPending(reason) {
65
71
  }
66
72
 
67
73
  /**
68
- * 模型侧发起授权请求。只登记 pendingRequest,不改变放行状态。
74
+ * 登记一个持续授权请求,不改变放行状态。
69
75
  * 返回的 signal 会在该请求被热键接管或被取消时 abort,调用方用它把「等人回答」的
70
76
  * promise 换成一个可取消的等待。
71
77
  */
72
- export function requestAuth(mode) {
73
- if (mode !== 'once' && mode !== 'persistent') {
74
- throw new Error(`requestAuth: mode 必须是 once 或 persistent,收到 ${mode}`)
75
- }
78
+ function startRequest() {
76
79
  const previousState = getStatus().state
77
80
  // 连着发两次请求时,前一张卡片不能变成悬空的:它一被答就撞上「已丢弃」,
78
81
  // 所以这里同样要 abort 掉,让前一次等待立刻正常返回。
@@ -80,13 +83,24 @@ export function requestAuth(mode) {
80
83
  state.requestSeq += 1
81
84
  const requestId = `auth-${state.requestSeq}`
82
85
  const controller = new AbortController()
83
- state.pendingRequest = { requestId, mode, requestedAt: Date.now(), controller }
84
- audit('requestAuth', 'tool', previousState, { requestId, mode })
86
+ state.pendingRequest = { requestId, mode: 'persistent', requestedAt: Date.now(), controller }
87
+ audit('requestAuth', 'tool', previousState, { requestId, mode: 'persistent' })
85
88
  return { requestId, signal: controller.signal, status: getStatus() }
86
89
  }
87
90
 
88
91
  /**
89
- * 人侧确认:pending-once → authorized-once;pending-persistent → persistent。
92
+ * 模型侧发起持续授权请求。只登记 pendingRequest,不改变放行状态。
93
+ * mode 只为接口稳定保留:V1.3 起只接受 'persistent',other 值直接报错。
94
+ */
95
+ export function requestAuth(mode = 'persistent') {
96
+ if (mode !== 'persistent') {
97
+ throw new Error(`requestAuth: mode 只支持 persistent(V1.3 已取消单次授权),收到 ${mode}`)
98
+ }
99
+ return startRequest()
100
+ }
101
+
102
+ /**
103
+ * 人侧确认:pending-persistent → persistent。
90
104
  * 只认 requestId 对应的那一次请求。对不上(已被热键接管或已取消)时不改状态、
91
105
  * 不写审计、不抛错,返回 confirmed:false —— 迟到的答案本来就该被丢弃。
92
106
  */
@@ -97,82 +111,66 @@ export function confirmPending(requestId) {
97
111
  }
98
112
  const previousState = getStatus().state
99
113
  state.pendingRequest = null
100
- if (request.mode === 'once') {
101
- state.onceGranted = true
102
- } else {
103
- state.persistent = true
104
- state.onceGranted = false
105
- }
114
+ state.persistent = true
115
+ state.epoch += 1
106
116
  request.controller.abort({ code: 'AUTH_CANCELLED', reason: '已由人确认' })
107
117
  audit('confirmPending', 'user-question', previousState, { mode: request.mode, requestId })
108
118
  return { ...getStatus(), confirmed: true }
109
119
  }
110
120
 
111
- /** 取消待确认请求与单次授权。persistent 不受影响(它只能由热键撤销)。 */
121
+ /** 取消待确认请求。persistent 不受影响(它只能由热键撤销)。 */
112
122
  export function cancelPending() {
113
123
  const previousState = getStatus().state
114
- state.onceGranted = false
115
124
  clearPending({ code: 'AUTH_CANCELLED', reason: '已取消' })
116
125
  audit('cancelPending', 'tool', previousState)
117
126
  return getStatus()
118
127
  }
119
128
 
120
129
  /**
121
- * 热键调用。热键优先级最高,且是 persistent 唯一的入口与出口:
122
- * persistent 中 → 撤销回 unauthorized;其余状态 → 进入 persistent(清 pending/authorized-once)。
123
- *
124
- * 撤销分支必须全清:persistent 期间人确认的「允许一次」会置 onceGranted,而 persistent 下的点击
125
- * 走 persistent 放行分支、不消费它。若撤销时不清 onceGranted,撤销后状态就成了 authorized-once,
126
- * 下一次点击仍会静默放行 —— 人以为已经收干净了。
130
+ * 热键调用,也是 persistent 唯一的入口与出口:
131
+ * persistent 中 → 撤销回 unauthorized;其余状态 → 进入 persistent(清 pending)。
127
132
  */
128
133
  export function togglePersistent(source = 'hotkey') {
129
134
  const previousState = getStatus().state
130
135
  if (state.persistent) {
131
136
  state.persistent = false
132
- state.onceGranted = false
133
137
  clearPending({ code: 'AUTH_CANCELLED', reason: '热键撤销' })
134
138
  } else {
135
139
  state.persistent = true
136
- state.onceGranted = false
137
140
  clearPending({ code: 'AUTH_CANCELLED', reason: '热键接管' })
138
141
  }
142
+ state.epoch += 1
139
143
  audit('togglePersistent', source, previousState)
140
144
  return getStatus()
141
145
  }
142
146
 
143
147
  /**
144
- * 消费一次放行。持续授权下放行且不改变状态;单次授权放行即清空。
145
- * 返回 { allowed, mode?, reason? }。
148
+ * 系统侧的强制回收:EventTap 失效时调用,无条件回到 unauthorized。
149
+ * 与 togglePersistent 的区别是它只会撤销、不会开启;pending 一并清掉,
150
+ * 因为这个状态机已经没有可信的人类急停通道了(fail-closed)。
146
151
  */
147
- export function consumeOnce() {
152
+ export function revokeAll(reason = 'system') {
148
153
  const previousState = getStatus().state
149
- if (state.persistent) {
150
- audit('consumeOnce', 'tool', previousState, { allowed: true, mode: 'persistent' })
151
- return { allowed: true, mode: 'persistent' }
152
- }
153
- if (state.onceGranted) {
154
- state.onceGranted = false
155
- audit('consumeOnce', 'tool', previousState, { allowed: true, mode: 'once' })
156
- return { allowed: true, mode: 'once' }
157
- }
158
- audit('consumeOnce', 'tool', previousState, {
159
- allowed: false,
160
- reason: state.pendingRequest !== null ? '授权请求待确认' : '未授权',
161
- })
162
- return {
163
- allowed: false,
164
- reason: state.pendingRequest !== null ? '授权请求待确认' : '未授权',
165
- }
154
+ state.persistent = false
155
+ clearPending({ code: 'AUTH_CANCELLED', reason })
156
+ state.epoch += 1
157
+ audit('revokeAll', 'system', previousState, { reason })
158
+ return getStatus()
166
159
  }
167
160
 
168
- /** vp_click 的放行判断(不消费)。 */
161
+ /** 当前放行态的代数(见 state.epoch 的注释)。 */
162
+ export function getAuthEpoch() {
163
+ return state.epoch
164
+ }
165
+
166
+ /**
167
+ * 鼠标与键盘共用的放行判断(不消费、不改状态)。
168
+ * 键盘不再有独立通道:persistent 下鼠标与键盘一律放行。
169
+ */
169
170
  export function checkCanClick() {
170
171
  if (state.persistent) {
171
172
  return { allowed: true, reason: '持续授权有效' }
172
173
  }
173
- if (state.onceGranted) {
174
- return { allowed: true, reason: '单次授权待使用' }
175
- }
176
174
  if (state.pendingRequest !== null) {
177
175
  return { allowed: false, reason: '授权请求待确认' }
178
176
  }
@@ -182,19 +180,15 @@ export function checkCanClick() {
182
180
  /** 测试专用:重置为未授权。 */
183
181
  export function _resetForTests() {
184
182
  state.persistent = false
185
- state.onceGranted = false
186
183
  clearPending({ code: 'AUTH_CANCELLED', reason: '测试重置' })
187
184
  state.requestSeq = 0
185
+ state.epoch += 1
188
186
  }
189
187
 
190
- /**
191
- * 测试专用:读内部标志位。persistent 期间 onceGranted 是否还挂着,
192
- * getStatus() 看不出来(persistent 优先返回 'persistent'),只能从这里取证。
193
- */
188
+ /** 测试专用:读内部标志位(getStatus 看不到 persistent 期间挂着的 pending 详情之外的东西)。 */
194
189
  export function _peekForTests() {
195
190
  return {
196
191
  persistent: state.persistent,
197
- onceGranted: state.onceGranted,
198
192
  hasPending: state.pendingRequest !== null,
199
193
  }
200
194
  }
package/src/diff.js CHANGED
@@ -14,7 +14,7 @@ import { join } from 'node:path'
14
14
  import { PNG } from 'pngjs'
15
15
  import pixelmatch from 'pixelmatch'
16
16
  import { screenshotAction } from './actions.js'
17
- import { get, recordRegions } from './shot-index.js'
17
+ import { get, recordRegions, assertShotFileExists } from './shot-index.js'
18
18
  import { resolveShotsDir } from './paths.js'
19
19
  import { generateGridImage, resolveGridScale } from './render.js'
20
20
 
@@ -186,6 +186,7 @@ export async function diffAction({
186
186
  `vp_screenshot_diff: baseline shotId ${baseline} 不存在,可先用 vp_screenshot 截一张`,
187
187
  )
188
188
  }
189
+ assertShotFileExists(baseline, baselineMeta, 'vp_screenshot_diff')
189
190
  const current = await screenshotAction()
190
191
  return diffPair({
191
192
  baselineMeta,
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/keys.js ADDED
@@ -0,0 +1,175 @@
1
+ // 按键写法解析:把 "enter" / "ctrl+c" / "f5" 这类人类写法翻译成 robot-ts 的 Key 码与修饰键。
2
+ //
3
+ // 键码不在这里写死:直接从 robot-ts 导出的 Key 枚举反查(枚举名即官方键名),
4
+ // 别名表只补口语写法(esc / del / pgup / 数字键…)。这样上游改键值时我们跟着走,
5
+ // 不会像写死常量那样悄悄注入错键。
6
+ //
7
+ // 解析是纯函数:Key/Modifier 枚举由调用方(actions.js)从 robot-ts 取出后传入,
8
+ // 这样本模块既不 import native,也不依赖宿主。
9
+
10
+ // 别名 → robot-ts Key 枚举名(小写形式)
11
+ const KEY_ALIASES = {
12
+ esc: 'escape',
13
+ return: 'enter',
14
+ del: 'delete',
15
+ ins: 'insert',
16
+ pgup: 'pageup',
17
+ pgdn: 'pagedown',
18
+ pgdown: 'pagedown',
19
+ up: 'uparrow',
20
+ down: 'downarrow',
21
+ left: 'leftarrow',
22
+ right: 'rightarrow',
23
+ plus: 'equal',
24
+ 1: 'digit1',
25
+ 2: 'digit2',
26
+ 3: 'digit3',
27
+ 4: 'digit4',
28
+ 5: 'digit5',
29
+ 6: 'digit6',
30
+ 7: 'digit7',
31
+ 8: 'digit8',
32
+ 9: 'digit9',
33
+ 0: 'digit0',
34
+ '-': 'minus',
35
+ '=': 'equal',
36
+ '[': 'leftbracket',
37
+ ']': 'rightbracket',
38
+ '\\': 'backslash',
39
+ ';': 'semicolon',
40
+ "'": 'quote',
41
+ '`': 'grave',
42
+ ',': 'comma',
43
+ '.': 'period',
44
+ '/': 'slash',
45
+ }
46
+
47
+ // 别名 → robot-ts Modifier 枚举值
48
+ const MODIFIER_ALIASES = {
49
+ ctrl: 'Control',
50
+ control: 'Control',
51
+ shift: 'Shift',
52
+ alt: 'Alt',
53
+ option: 'Alt',
54
+ meta: 'Meta',
55
+ cmd: 'Meta',
56
+ win: 'Meta',
57
+ super: 'Meta',
58
+ }
59
+
60
+ // 字符键 → 它在文本里对应的字符。
61
+ //
62
+ // 只给「无修饰键的字符键」准备:这类键改走 Unicode 直注(见 resolveUnicodeChar)。
63
+ // 原因是 robot-ts 的按键注入走 KEYEVENTF_SCANCODE,会经过系统输入法 ——
64
+ // 本机中文输入法实测会把字母键吞成拼音组合串(按下不落字,直到下一次 Enter 才吐出幽灵字符),
65
+ // 还会把引号改写成中文弯引号。Unicode 直注绕开输入法,字符原样落下。
66
+ //
67
+ // space 不在表里:扫描码空格实测正常,而且只有真实按键才能激活聚焦的按钮。
68
+ const CHARACTER_KEYS = {
69
+ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f', g: 'g', h: 'h', i: 'i', j: 'j',
70
+ k: 'k', l: 'l', m: 'm', n: 'n', o: 'o', p: 'p', q: 'q', r: 'r', s: 's', t: 't',
71
+ u: 'u', v: 'v', w: 'w', x: 'x', y: 'y', z: 'z',
72
+ digit1: '1', digit2: '2', digit3: '3', digit4: '4', digit5: '5',
73
+ digit6: '6', digit7: '7', digit8: '8', digit9: '9', digit0: '0',
74
+ minus: '-', equal: '=', leftbracket: '[', rightbracket: ']', backslash: '\\',
75
+ nonushash: '#', semicolon: ';', quote: "'", grave: '`', comma: ',', period: '.', slash: '/',
76
+ }
77
+
78
+ export const MODIFIER_NAMES = ['ctrl', 'shift', 'alt', 'meta']
79
+
80
+ /** Key 枚举 → 小写名到码值的索引(枚举成员名即官方键名)。 */
81
+ function keyIndex(keyEnum) {
82
+ const index = new Map()
83
+ for (const [name, value] of Object.entries(keyEnum)) {
84
+ if (typeof value === 'number') index.set(name.toLowerCase(), value)
85
+ }
86
+ return index
87
+ }
88
+
89
+ /**
90
+ * 解析按键写法。
91
+ * 返回 { code, keyName, modifiers, spec }:
92
+ * code robot-ts 的 Key 码(数字)
93
+ * keyName 规范化的主键名(robot-ts 枚举名)
94
+ * modifiers 修饰键枚举值数组,如 ['Control']
95
+ * spec 原样回显的写法
96
+ * 解析失败一律抛错,错误文案里带上可用的修饰键名。
97
+ */
98
+ export function parseKeySpec(spec, keyEnum, modifierEnum) {
99
+ if (typeof spec !== 'string') {
100
+ throw new Error(`vp_key: key 必须是字符串,收到 ${spec === null ? 'null' : typeof spec}`)
101
+ }
102
+ const trimmed = spec.trim()
103
+ if (trimmed === '') {
104
+ throw new Error('vp_key: key 不能为空字符串')
105
+ }
106
+
107
+ const parts = trimmed
108
+ .split('+')
109
+ .map((part) => part.trim())
110
+ .filter((part) => part !== '')
111
+ if (parts.length === 0) {
112
+ throw new Error('vp_key: key 不能为空字符串')
113
+ }
114
+
115
+ const modifiers = []
116
+ for (const part of parts.slice(0, -1)) {
117
+ const modifier = MODIFIER_ALIASES[part.toLowerCase()]
118
+ if (modifier === undefined) {
119
+ throw new Error(
120
+ `vp_key: 不认识的修饰键 "${part}"(可用:${MODIFIER_NAMES.join(' / ')})`,
121
+ )
122
+ }
123
+ if (!Object.values(modifierEnum).includes(modifier)) {
124
+ throw new Error(`vp_key: robot-ts 不支持修饰键 "${modifier}"`)
125
+ }
126
+ modifiers.push(modifier)
127
+ }
128
+
129
+ const rawKey = parts[parts.length - 1]
130
+ const lowerKey = rawKey.toLowerCase()
131
+ if (MODIFIER_ALIASES[lowerKey] !== undefined) {
132
+ throw new Error(
133
+ `vp_key: "${rawKey}" 只是修饰键,必须同时给一个主键(例如 ctrl+c、shift+tab)`,
134
+ )
135
+ }
136
+
137
+ const canonical = KEY_ALIASES[lowerKey] ?? lowerKey
138
+ const code = keyIndex(keyEnum).get(canonical)
139
+ if (code === undefined) {
140
+ throw new Error(
141
+ `vp_key: 不认识的按键 "${rawKey}"。可用示例:enter / esc / tab / space / backspace / ` +
142
+ 'delete / up / down / left / right / f1-f12 / a-z / 0-9 / ctrl+c / shift+tab',
143
+ )
144
+ }
145
+
146
+ return {
147
+ code,
148
+ keyName: canonical,
149
+ modifiers,
150
+ spec: trimmed,
151
+ }
152
+ }
153
+
154
+ /** 该键是否是一个字符键;是则返回它对应的字符,否则返回 null。 */
155
+ export function characterForKey(keyName) {
156
+ return CHARACTER_KEYS[String(keyName).toLowerCase()] ?? null
157
+ }
158
+
159
+ /**
160
+ * 判断这个键是否该改走 Unicode 直注,返回要注入的字符;不该改走就返回 null。
161
+ *
162
+ * 规则(依据本机实测):
163
+ * - 无修饰键的字符键 → 注入该字符本身
164
+ * - 只带 shift 的字母键 → 注入大写字母
165
+ * - 其余(带 ctrl/alt/meta 的组合键、控制键、space)→ null,走真实扫描码
166
+ */
167
+ export function resolveUnicodeChar(spec) {
168
+ const base = characterForKey(spec.keyName)
169
+ if (base === null) return null
170
+ if (spec.modifiers.length === 0) return base
171
+ if (spec.modifiers.length === 1 && spec.modifiers[0] === 'Shift' && /^[a-z]$/.test(base)) {
172
+ return base.toUpperCase()
173
+ }
174
+ return null
175
+ }