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/README.md +64 -12
- package/package.json +2 -2
- package/src/actions.js +184 -31
- package/src/audit.js +4 -2
- package/src/auth.js +73 -79
- package/src/diff.js +2 -1
- package/src/hotkey.js +87 -23
- package/src/index.js +7 -1
- package/src/keys.js +175 -0
- package/src/plugin.js +330 -85
- package/src/shot-index.js +26 -1
- package/src/summarize.js +131 -0
- package/src/wait.js +19 -1
package/src/auth.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// Vision-Pilot 授权状态机(宿主无关,纯内存单例)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// 授权模型(V1.3):只有两种权限结论 + 一种等待态。
|
|
4
|
+
// unauthorized 未授权,一切鼠标与键盘操作直接拦截
|
|
5
|
+
// pending-persistent 已请求持续授权、等待人类在卡片上确认
|
|
6
|
+
// persistent 持续授权中,鼠标与键盘一律直接放行
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// pending-once 已请求单次授权、等待人类确认(带 pendingRequest)
|
|
10
|
-
// authorized-once 已确认、等待下一次点击消费(无 pendingRequest)
|
|
11
|
-
// pending-persistent 已请求持续授权、等待人类确认
|
|
12
|
-
// persistent 持续授权中,无到期
|
|
8
|
+
// 铁律:授权只由人的明确动作改变——按 Shift+Space,或在卡片上确认 persistent 请求。
|
|
9
|
+
// 不由时钟改变:没有任何到期判断,也不返回 remainingMs。
|
|
13
10
|
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
17
|
+
// 每次授权动作与每次被拒绝/放行的注入都追加审计记录,见 src/audit.js。
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
74
|
+
* 登记一个持续授权请求,不改变放行状态。
|
|
69
75
|
* 返回的 signal 会在该请求被热键接管或被取消时 abort,调用方用它把「等人回答」的
|
|
70
76
|
* promise 换成一个可取消的等待。
|
|
71
77
|
*/
|
|
72
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
122
|
-
* persistent 中 → 撤销回 unauthorized;其余状态 → 进入 persistent(清 pending
|
|
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
|
-
*
|
|
148
|
+
* 系统侧的强制回收:EventTap 失效时调用,无条件回到 unauthorized。
|
|
149
|
+
* 与 togglePersistent 的区别是它只会撤销、不会开启;pending 一并清掉,
|
|
150
|
+
* 因为这个状态机已经没有可信的人类急停通道了(fail-closed)。
|
|
146
151
|
*/
|
|
147
|
-
export function
|
|
152
|
+
export function revokeAll(reason = 'system') {
|
|
148
153
|
const previousState = getStatus().state
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
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
|
-
/**
|
|
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
|
|
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/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
|
+
}
|