@pointylab/dsh 0.0.0-stage → 0.4.4

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.
Files changed (88) hide show
  1. package/ASSETS.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +85 -2
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +33 -0
  6. package/package.json +38 -4
  7. package/pet/app/card/card.css +365 -0
  8. package/pet/app/card/card.html +68 -0
  9. package/pet/app/card/card.js +896 -0
  10. package/pet/app/card/markdown.js +64 -0
  11. package/pet/app/card/mascot/blink.png +0 -0
  12. package/pet/app/card/mascot/done.png +0 -0
  13. package/pet/app/card/mascot/error.png +0 -0
  14. package/pet/app/card/mascot/idle.png +0 -0
  15. package/pet/app/card/mascot/peek.png +0 -0
  16. package/pet/app/card/mascot/think.png +0 -0
  17. package/pet/app/card/preload.js +21 -0
  18. package/pet/app/fullscreen.js +131 -0
  19. package/pet/app/main.js +1284 -0
  20. package/pet/app/native/uia-selection.ps1 +158 -0
  21. package/pet/app/notice/notice.html +37 -0
  22. package/pet/app/notice/notice.js +31 -0
  23. package/pet/app/notice/preload.js +7 -0
  24. package/pet/app/overlay/overlay.html +30 -0
  25. package/pet/app/overlay/overlay.js +42 -0
  26. package/pet/app/overlay/preload.js +7 -0
  27. package/pet/app/pill/pill.css +73 -0
  28. package/pet/app/pill/pill.html +11 -0
  29. package/pet/app/pill/pill.js +35 -0
  30. package/pet/app/pill/preload.js +8 -0
  31. package/pet/app/preload.js +16 -0
  32. package/pet/app/renderer/index.html +24 -0
  33. package/pet/app/renderer/lines.js +25 -0
  34. package/pet/app/renderer/pet.js +292 -0
  35. package/pet/app/renderer/renderer2d.js +71 -0
  36. package/pet/app/renderer/style.css +157 -0
  37. package/pet/app/selection.js +246 -0
  38. package/pet/app/shake.js +76 -0
  39. package/pet/app/uipick.js +314 -0
  40. package/pet/assets/cursor/cursor-32.cur +0 -0
  41. package/pet/assets/cursor/cursor-32.png +0 -0
  42. package/pet/assets/cursor/cursor-48.cur +0 -0
  43. package/pet/assets/cursor/cursor-48.png +0 -0
  44. package/pet/assets/cursor/cursor-64.cur +0 -0
  45. package/pet/assets/cursor/cursor-64.png +0 -0
  46. package/pet/assets/cursor/cursor-clean.png +0 -0
  47. package/pet/assets/cursor/make_cursor.py +85 -0
  48. package/pet/assets/cursor/source.png +0 -0
  49. package/pet/assets/pointy.ico +0 -0
  50. package/pet/assets/tray.png +0 -0
  51. package/pet/assets/tray@2x.png +0 -0
  52. package/pet/boot.js +6 -0
  53. package/pet/extension/background.js +252 -0
  54. package/pet/extension/cursor-32.png +0 -0
  55. package/pet/extension/cursor-48.png +0 -0
  56. package/pet/extension/cursor-64.png +0 -0
  57. package/pet/extension/icon.png +0 -0
  58. package/pet/extension/manifest.json +12 -0
  59. package/pet/extension/pick.js +391 -0
  60. package/pet/extension/popup.html +29 -0
  61. package/pet/extension/popup.js +30 -0
  62. package/pet/package.json +11 -0
  63. package/pet/vendor/koffi/LICENSE.txt +22 -0
  64. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
  65. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
  66. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
  67. package/pet/vendor/koffi/index.js +634 -0
  68. package/pet/vendor/koffi/indirect.js +533 -0
  69. package/pet/vendor/koffi/package.json +35 -0
  70. package/pet/vendor/ws/LICENSE +20 -0
  71. package/pet/vendor/ws/index.js +22 -0
  72. package/pet/vendor/ws/lib/buffer-util.js +142 -0
  73. package/pet/vendor/ws/lib/constants.js +19 -0
  74. package/pet/vendor/ws/lib/event-target.js +292 -0
  75. package/pet/vendor/ws/lib/extension.js +203 -0
  76. package/pet/vendor/ws/lib/limiter.js +55 -0
  77. package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
  78. package/pet/vendor/ws/lib/receiver.js +743 -0
  79. package/pet/vendor/ws/lib/sender.js +610 -0
  80. package/pet/vendor/ws/lib/stream.js +161 -0
  81. package/pet/vendor/ws/lib/subprotocol.js +62 -0
  82. package/pet/vendor/ws/lib/validation.js +152 -0
  83. package/pet/vendor/ws/lib/websocket-server.js +562 -0
  84. package/pet/vendor/ws/lib/websocket.js +1426 -0
  85. package/pet/vendor/ws/package.json +74 -0
  86. package/pet/vendor/ws/wrapper.mjs +21 -0
  87. package/src/node.js +732 -0
  88. package/src/runtime.js +178 -0
@@ -0,0 +1,246 @@
1
+ // 按下快捷键的那一刻取"用户选中了什么":先认前台程序,浏览器问扩展,
2
+ // 其他程序用 UI Automation,再不行就模拟一次 Ctrl+C 并把剪贴板还原。只在被调用时读,不做任何监听。
3
+ const koffi = require('koffi')
4
+ const { clipboard } = require('electron')
5
+ const { spawn } = require('node:child_process')
6
+ const path = require('node:path')
7
+
8
+ const user32 = koffi.load('user32.dll')
9
+ const kernel32 = koffi.load('kernel32.dll')
10
+ const GetForegroundWindow = user32.func('void * __stdcall GetForegroundWindow()')
11
+ const GetWindowTextW = user32.func('int __stdcall GetWindowTextW(void *hwnd, _Out_ uint16 *text, int max)')
12
+ const GetWindowThreadProcessId = user32.func('uint32 __stdcall GetWindowThreadProcessId(void *hwnd, _Out_ uint32 *pid)')
13
+ const GetAsyncKeyState = user32.func('int16 __stdcall GetAsyncKeyState(int key)')
14
+ const keybd_event = user32.func('void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr extra)')
15
+ const GetClipboardSequenceNumber = user32.func('uint32 __stdcall GetClipboardSequenceNumber()')
16
+ const OpenProcess = kernel32.func('void * __stdcall OpenProcess(uint32 access, bool inherit, uint32 pid)')
17
+ const QueryFullProcessImageNameW = kernel32.func('bool __stdcall QueryFullProcessImageNameW(void *process, uint32 flags, _Out_ uint16 *name, _Inout_ uint32 *size)')
18
+ const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *handle)')
19
+ // koffi 的类型名是全局的,shake.js 已经注册过 POINT
20
+ koffi.struct('SEL_POINT', { x: 'int32', y: 'int32' })
21
+ const GetCursorPos = user32.func('bool __stdcall GetCursorPos(_Out_ SEL_POINT *p)')
22
+ const WindowFromPoint = user32.func('void * __stdcall WindowFromPoint(SEL_POINT p)')
23
+ const GetAncestor = user32.func('void * __stdcall GetAncestor(void *hwnd, uint32 flags)')
24
+ const advapi32 = koffi.load('advapi32.dll')
25
+ const OpenProcessToken = advapi32.func('bool __stdcall OpenProcessToken(void *process, uint32 access, _Out_ void **token)')
26
+ const GetTokenInformation = advapi32.func('bool __stdcall GetTokenInformation(void *token, int cls, _Out_ uint32 *info, uint32 len, _Out_ uint32 *ret)')
27
+ const GA_ROOT = 2
28
+ const TOKEN_QUERY = 8
29
+ const TokenElevation = 20
30
+
31
+ const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
32
+ const VK_SHIFT = 0x10
33
+ const VK_CONTROL = 0x11
34
+ const VK_MENU = 0x12
35
+ const VK_SPACE = 0x20
36
+ const VK_LWIN = 0x5b
37
+ const VK_RWIN = 0x5c
38
+ const VK_C = 0x43
39
+ const SC_CONTROL = 0x1d
40
+ const SC_C = 0x2e
41
+ const KEYEVENTF_KEYUP = 2
42
+
43
+ const BROWSERS = new Set(['chrome.exe', 'msedge.exe', 'brave.exe', 'vivaldi.exe'])
44
+ // 密码管理器、系统凭据框:碰都不碰
45
+ const BLOCKED_EXES = new Set([
46
+ 'keepass.exe', 'keepassxc.exe', '1password.exe', 'bitwarden.exe', 'lastpass.exe', 'enpass.exe', 'dashlane.exe',
47
+ 'roboform.exe', 'credentialuibroker.exe', 'consent.exe', 'logonui.exe', 'lockapp.exe',
48
+ ])
49
+ const BLOCKED_TITLE = /密码|口令|password|passcode|登录|登陆|sign[ -]?in|log[ -]?in|网上银行|网银|online banking|支付宝|alipay/i
50
+ // 终端里 Ctrl+C 是中断;带内置终端的编辑器焦点可能就在终端里。这些程序只用 UI Automation,绝不模拟 Ctrl+C
51
+ const NO_COPY_EXES = new Set([
52
+ 'windowsterminal.exe', 'wezterm-gui.exe', 'cmd.exe', 'powershell.exe', 'pwsh.exe', 'conhost.exe', 'openconsole.exe',
53
+ 'alacritty.exe', 'mintty.exe', 'wsl.exe', 'putty.exe', 'kitty.exe', 'tabby.exe', 'hyper.exe', 'warp.exe',
54
+ 'code.exe', 'cursor.exe', 'antigravity.exe', 'windsurf.exe', 'idea64.exe', 'pycharm64.exe', 'webstorm64.exe',
55
+ ])
56
+
57
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
58
+
59
+ function utf16(buf, chars) {
60
+ return buf.toString('utf16le', 0, chars * 2)
61
+ }
62
+
63
+ function processPath(pid) {
64
+ const handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
65
+ if (!handle) return ''
66
+ try {
67
+ const buf = Buffer.alloc(2048)
68
+ const size = [1024]
69
+ return QueryFullProcessImageNameW(handle, 0, buf, size) ? utf16(buf, size[0]) : ''
70
+ } finally {
71
+ CloseHandle(handle)
72
+ }
73
+ }
74
+
75
+ /** 进程是不是以管理员身份运行(查不了就当不是) */
76
+ function isElevated(pid) {
77
+ const handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
78
+ if (!handle) return false
79
+ try {
80
+ const token = [null]
81
+ if (!OpenProcessToken(handle, TOKEN_QUERY, token)) return false
82
+ try {
83
+ const info = [0]
84
+ return GetTokenInformation(token[0], TokenElevation, info, 4, [0]) && info[0] !== 0
85
+ } finally {
86
+ CloseHandle(token[0])
87
+ }
88
+ } finally {
89
+ CloseHandle(handle)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 鼠标下面那个窗口是不是管理员程序。Pointy 是普通权限,Windows 不让它读管理员程序里的控件,
95
+ * 鼠标钩子在那上面也收不到点击(确认、取消都失效),所以这种窗口不进指认
96
+ */
97
+ function elevatedUnderCursor() {
98
+ const pt = {}
99
+ if (!GetCursorPos(pt)) return false
100
+ const hwnd = WindowFromPoint(pt)
101
+ if (!hwnd) return false
102
+ const root = GetAncestor(hwnd, GA_ROOT) ?? hwnd
103
+ const pid = [0]
104
+ GetWindowThreadProcessId(root, pid)
105
+ return pid[0] ? isElevated(pid[0]) : false
106
+ }
107
+
108
+ /** 前台窗口:标题、进程名 */
109
+ function foreground() {
110
+ const hwnd = GetForegroundWindow()
111
+ if (!hwnd) return { title: '', exe: '', exePath: '' }
112
+ const buf = Buffer.alloc(1024)
113
+ const title = utf16(buf, GetWindowTextW(hwnd, buf, 512))
114
+ const pid = [0]
115
+ GetWindowThreadProcessId(hwnd, pid)
116
+ const exePath = processPath(pid[0])
117
+ return { hwnd: koffi.address(hwnd), title, pid: pid[0], exePath, exe: path.basename(exePath).toLowerCase() }
118
+ }
119
+
120
+ function blockedReason(fg) {
121
+ if (BLOCKED_EXES.has(fg.exe)) return `${fg.exe} 在黑名单里`
122
+ if (BLOCKED_TITLE.test(fg.title)) return '窗口像是登录或密码页面'
123
+ return undefined
124
+ }
125
+
126
+ /** UI Automation 走一个常驻的 PowerShell 小进程,省掉每次 .NET 启动的时间 */
127
+ class UiaHelper {
128
+ start() {
129
+ this.proc = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', path.join(__dirname, 'native', 'uia-selection.ps1')], { windowsHide: true })
130
+ this.proc.stdout.setEncoding('utf8')
131
+ this.buffer = ''
132
+ this.waiters = []
133
+ this.proc.stdout.on('data', (chunk) => {
134
+ this.buffer += chunk
135
+ let i
136
+ while ((i = this.buffer.indexOf('\n')) >= 0) {
137
+ const line = this.buffer.slice(0, i).trim()
138
+ this.buffer = this.buffer.slice(i + 1)
139
+ let parsed
140
+ try { parsed = JSON.parse(line) } catch { continue }
141
+ this.waiters.shift()?.(parsed)
142
+ }
143
+ })
144
+ this.proc.on('exit', () => {
145
+ this.proc = undefined
146
+ for (const w of this.waiters.splice(0)) w(undefined)
147
+ })
148
+ }
149
+
150
+ grab(timeoutMs = 1500) {
151
+ return this.request('grab', timeoutMs)
152
+ }
153
+
154
+ /** 发一行命令(grab / point X Y / element X Y UP),等一行 JSON 回来 */
155
+ request(command, timeoutMs = 1500) {
156
+ if (!this.proc) this.start()
157
+ return new Promise((resolve) => {
158
+ let done = false
159
+ const finish = (v) => { if (!done) { done = true; resolve(v) } }
160
+ this.waiters.push(finish)
161
+ this.proc.stdin.write(`${command}\n`)
162
+ setTimeout(() => {
163
+ if (done) return
164
+ // 超时的那次回答以后会错位,干脆重启
165
+ this.stop()
166
+ finish(undefined)
167
+ }, timeoutMs)
168
+ })
169
+ }
170
+
171
+ stop() {
172
+ this.proc?.kill()
173
+ this.proc = undefined
174
+ }
175
+ }
176
+
177
+ const uia = new UiaHelper()
178
+
179
+ /** 等用户松开快捷键里的修饰键,否则模拟的 Ctrl+C 会变成 Ctrl+Alt+C */
180
+ async function waitKeysReleased(timeoutMs = 800) {
181
+ const keys = [VK_SHIFT, VK_CONTROL, VK_MENU, VK_SPACE, VK_LWIN, VK_RWIN]
182
+ const end = Date.now() + timeoutMs
183
+ while (Date.now() < end) {
184
+ if (keys.every(k => (GetAsyncKeyState(k) & 0x8000) === 0)) return true
185
+ await sleep(20)
186
+ }
187
+ return false
188
+ }
189
+
190
+ /** 模拟 Ctrl+C 拿选区,拿完把剪贴板原样放回(文本、HTML、RTF、图片) */
191
+ async function copySelection() {
192
+ if (!await waitKeysReleased()) return ''
193
+ const snapshot = { text: clipboard.readText(), html: clipboard.readHTML(), rtf: clipboard.readRTF(), image: clipboard.readImage() }
194
+ const before = GetClipboardSequenceNumber()
195
+ // 带上扫描码:Chromium/Electron 程序(QQNT 等)对扫描码为 0 的模拟按键不一定认
196
+ keybd_event(VK_CONTROL, SC_CONTROL, 0, 0)
197
+ keybd_event(VK_C, SC_C, 0, 0)
198
+ keybd_event(VK_C, SC_C, KEYEVENTF_KEYUP, 0)
199
+ keybd_event(VK_CONTROL, SC_CONTROL, KEYEVENTF_KEYUP, 0)
200
+ // Electron 程序复制是异步的,多等一会儿
201
+ const end = Date.now() + 1200
202
+ while (Date.now() < end && GetClipboardSequenceNumber() === before) await sleep(15)
203
+ if (GetClipboardSequenceNumber() === before) return ''
204
+ await sleep(30)
205
+ const text = clipboard.readText()
206
+ const restore = {}
207
+ if (snapshot.text) restore.text = snapshot.text
208
+ if (snapshot.html) restore.html = snapshot.html
209
+ if (snapshot.rtf) restore.rtf = snapshot.rtf
210
+ if (!snapshot.image.isEmpty()) restore.image = snapshot.image
211
+ if (Object.keys(restore).length) clipboard.write(restore)
212
+ else clipboard.clear()
213
+ return text
214
+ }
215
+
216
+ /**
217
+ * @param extensionGrabFor (exe) => 向该浏览器里的扩展要选区的函数;没有可用扩展时返回 undefined
218
+ * @returns {{ source, text?, url?, pageTitle?, before?, after?, method, blocked? }}
219
+ */
220
+ async function grabSelection(extensionGrabFor, { allowCopy = true } = {}) {
221
+ const fg = foreground()
222
+ const source = { app: fg.exe, title: fg.title }
223
+ const blocked = blockedReason(fg)
224
+ if (blocked) return { source, blocked, method: 'none' }
225
+ const extensionGrab = extensionGrabFor?.(fg.exe)
226
+ if (extensionGrab) {
227
+ const r = await extensionGrab().catch(() => undefined)
228
+ if (r?.blocked) return { source, blocked: r.blocked, method: 'extension' }
229
+ if (r && (r.text || r.url)) {
230
+ if (BLOCKED_TITLE.test(r.pageTitle ?? '')) return { source, blocked: '页面像是登录或密码页面', method: 'extension' }
231
+ return { source, method: 'extension', ...r }
232
+ }
233
+ }
234
+ const u = await uia.grab()
235
+ if (u?.isPassword) return { source, blocked: '焦点在密码框里', method: 'uia' }
236
+ if (u?.ok && u.text?.trim()) return { source, method: 'uia', text: u.text }
237
+ if (!allowCopy || NO_COPY_EXES.has(fg.exe)) return { source, method: 'none', text: '' }
238
+ const text = await copySelection()
239
+ return { source, method: text ? 'clipboard' : 'none', text }
240
+ }
241
+
242
+ module.exports = {
243
+ grabSelection, foreground, blockedReason, elevatedUnderCursor, isElevated, copySelection, NO_COPY_EXES, BROWSERS,
244
+ uiaRequest: (command, timeoutMs) => uia.request(command, timeoutMs),
245
+ stopSelectionHelper: () => uia.stop(),
246
+ }
@@ -0,0 +1,76 @@
1
+ // "晃一下鼠标"检测:每 16ms 读一次光标位置,只在内存里保留最近一小段水平移动,用来数方向折返。
2
+ // 不装鼠标钩子,拿不到点击和按键,也不记录轨迹。
3
+ const koffi = require('koffi')
4
+
5
+ const user32 = koffi.load('user32.dll')
6
+ const POINT = koffi.struct('POINT', { x: 'int32', y: 'int32' })
7
+ const CURSORINFO = koffi.struct('CURSORINFO', { cbSize: 'uint32', flags: 'uint32', hCursor: 'intptr', ptScreenPos: POINT })
8
+ const GetCursorPos = user32.func('bool __stdcall GetCursorPos(_Out_ POINT *p)')
9
+ const GetCursorInfo = user32.func('bool __stdcall GetCursorInfo(_Inout_ CURSORINFO *info)')
10
+ const GetAsyncKeyState = user32.func('int16 __stdcall GetAsyncKeyState(int key)')
11
+
12
+ const CURSOR_SHOWING = 1
13
+ const VK_LBUTTON = 1
14
+ const VK_RBUTTON = 2
15
+ const VK_MBUTTON = 4
16
+ const POLL_MS = 16
17
+ const COOLDOWN_MS = 1500
18
+
19
+ // 灵敏度:窗口时长、至少几次折返、每段至少多少像素(物理像素)
20
+ const LEVELS = {
21
+ low: { windowMs: 400, reversals: 4, amplitude: 60 },
22
+ medium: { windowMs: 450, reversals: 3, amplitude: 40 },
23
+ high: { windowMs: 500, reversals: 3, amplitude: 28 },
24
+ }
25
+ // 晃的时候上下不能跑太远,否则是在正常移动
26
+ const MAX_VERTICAL = 120
27
+
28
+ function cursorVisible() {
29
+ const info = { cbSize: koffi.sizeof(CURSORINFO), flags: 0, hCursor: 0, ptScreenPos: { x: 0, y: 0 } }
30
+ return GetCursorInfo(info) && (info.flags & CURSOR_SHOWING) !== 0
31
+ }
32
+
33
+ const buttonDown = () => [VK_LBUTTON, VK_RBUTTON, VK_MBUTTON].some(k => (GetAsyncKeyState(k) & 0x8000) !== 0)
34
+
35
+ /**
36
+ * @param onShake (x, y) => void,x/y 是物理像素坐标
37
+ * @param shouldIgnore () => boolean,全屏、勿扰、正在指认时返回 true
38
+ */
39
+ function startShakeDetector({ onShake, shouldIgnore, level = () => 'medium' }) {
40
+ let samples = [] // { t, x, y },只留窗口时长内的
41
+ let lastFire = 0
42
+ const p = { x: 0, y: 0 }
43
+
44
+ const timer = setInterval(() => {
45
+ if (!GetCursorPos(p)) return
46
+ const now = Date.now()
47
+ const cfg = LEVELS[level()] ?? LEVELS.medium
48
+ samples.push({ t: now, x: p.x, y: p.y })
49
+ samples = samples.filter(s => now - s.t <= cfg.windowMs)
50
+ if (samples.length < 6 || now - lastFire < COOLDOWN_MS) return
51
+
52
+ // 记住当前方向上走到的最远点,从最远点往回走够 amplitude 才算一次折返,小抖动不算
53
+ let reversals = 0
54
+ let dir = 0
55
+ let extreme = samples[0].x
56
+ for (const { x } of samples.slice(1)) {
57
+ if (dir === 0) {
58
+ if (Math.abs(x - extreme) >= cfg.amplitude) { dir = Math.sign(x - extreme); extreme = x }
59
+ continue
60
+ }
61
+ if ((x - extreme) * dir > 0) { extreme = x; continue }
62
+ if (Math.abs(extreme - x) >= cfg.amplitude) { reversals++; dir = -dir; extreme = x }
63
+ }
64
+ const ys = samples.map(s => s.y)
65
+ if (reversals < cfg.reversals) return
66
+ if (Math.max(...ys) - Math.min(...ys) > MAX_VERTICAL) return
67
+ if (shouldIgnore() || buttonDown() || !cursorVisible()) return
68
+ lastFire = now
69
+ samples = []
70
+ onShake(p.x, p.y)
71
+ }, POLL_MS)
72
+
73
+ return () => clearInterval(timer)
74
+ }
75
+
76
+ module.exports = { startShakeDetector, LEVELS }
@@ -0,0 +1,314 @@
1
+ // 普通程序里的指认:一层鼠标穿透的透明遮罩画高亮框,UI Automation 查光标下的控件。
2
+ // 遮罩让鼠标穿过去(不然查到的是遮罩自己),点击靠指认期间的低级鼠标钩子接住:左键选中、右键退出;
3
+ // 空格确认、Esc、[ ] 调范围这几个键只在指认期间注册,空格留着给钩子装不上时用。
4
+ const { BrowserWindow, desktopCapturer, globalShortcut, screen } = require('electron')
5
+ const koffi = require('koffi')
6
+ const path = require('node:path')
7
+ const { uiaRequest } = require('./selection')
8
+ const { foregroundAddress, setOwner } = require('./fullscreen')
9
+
10
+ const user32 = koffi.load('user32.dll')
11
+ const GetAsyncKeyState = user32.func('int16 __stdcall GetAsyncKeyState(int key)')
12
+ const VK_RBUTTON = 2
13
+
14
+ // 指认期间装一个低级鼠标钩子,把右键按下和抬起都吃掉:下面的程序完全收不到,也就不会弹它的右键菜单
15
+ const LowLevelMouseProc = koffi.proto('intptr __stdcall LowLevelMouseProc(int nCode, uintptr wParam, void *lParam)')
16
+ const SetWindowsHookExW = user32.func('intptr __stdcall SetWindowsHookExW(int idHook, LowLevelMouseProc *proc, intptr hmod, uint32 threadId)')
17
+ const CallNextHookEx = user32.func('intptr __stdcall CallNextHookEx(intptr hhk, int nCode, uintptr wParam, void *lParam)')
18
+ const UnhookWindowsHookEx = user32.func('int __stdcall UnhookWindowsHookEx(intptr hhk)')
19
+ const GetModuleHandleW = koffi.load('kernel32.dll').func('intptr __stdcall GetModuleHandleW(intptr name)')
20
+ const WH_MOUSE_LL = 14
21
+ const WM_RBUTTONDOWN = 0x0204
22
+ const WM_RBUTTONUP = 0x0205
23
+ const WM_LBUTTONDOWN = 0x0201
24
+ const WM_LBUTTONUP = 0x0202
25
+ const WM_MOUSEWHEEL = 0x020A
26
+
27
+ /**
28
+ * 吃掉左右键:左键抬起 = 选中,右键抬起 = 取消;下面的程序都收不到。装不上返回 undefined(退回轮询 + 空格)
29
+ * wantWheel() 为真时滚轮也吃掉,交给 onWheel(+1 往上滚 / -1 往下滚)
30
+ */
31
+ function hookButtons({ onLeftUp, onRightUp, onWheel, wantWheel }) {
32
+ let hook = 0
33
+ const proc = koffi.register((nCode, wParam, lParam) => {
34
+ const msg = Number(wParam)
35
+ if (nCode >= 0 && msg === WM_MOUSEWHEEL && wantWheel?.()) {
36
+ // MSLLHOOKSTRUCT.mouseData 在第 8 字节,高 16 位是滚动量
37
+ let delta = 0
38
+ try { delta = koffi.decode(lParam, 8, 'int32') >> 16 } catch {}
39
+ if (delta) setImmediate(() => onWheel(Math.sign(delta)))
40
+ return 1
41
+ }
42
+ if (nCode >= 0 && (msg === WM_RBUTTONDOWN || msg === WM_RBUTTONUP || msg === WM_LBUTTONDOWN || msg === WM_LBUTTONUP)) {
43
+ // 钩子里要马上返回,收尾放到下一轮
44
+ if (msg === WM_RBUTTONUP) setImmediate(onRightUp)
45
+ if (msg === WM_LBUTTONUP) setImmediate(onLeftUp)
46
+ return 1
47
+ }
48
+ return CallNextHookEx(0, nCode, wParam, lParam)
49
+ }, koffi.pointer(LowLevelMouseProc))
50
+ try {
51
+ hook = SetWindowsHookExW(WH_MOUSE_LL, proc, GetModuleHandleW(0), 0)
52
+ } catch {}
53
+ if (!hook) { koffi.unregister(proc); return undefined }
54
+ return () => { UnhookWindowsHookEx(hook); koffi.unregister(proc) }
55
+ }
56
+
57
+ // 指认期间把系统光标换成鲸鱼娘扒着箭头(箭头、文字 I 形、小手、十字都换,指到哪都是她);
58
+ // 结束时让系统从注册表重新加载用户自己的光标方案,不改注册表
59
+ const LoadImageW = user32.func('intptr __stdcall LoadImageW(intptr hinst, const char16_t *name, uint32 type, int cx, int cy, uint32 flags)')
60
+ const SetSystemCursor = user32.func('bool __stdcall SetSystemCursor(intptr hcur, uint32 id)')
61
+ const SystemParametersInfoW = user32.func('bool __stdcall SystemParametersInfoW(uint32 action, uint32 param, intptr pv, uint32 winini)')
62
+ const IMAGE_CURSOR = 2
63
+ const LR_LOADFROMFILE = 0x10
64
+ const SPI_SETCURSORS = 0x57
65
+ const SWAPPED_CURSORS = [32512, 32513, 32649, 32515]
66
+ const CURSOR_SIZES = [32, 48, 64]
67
+ let cursorSwapped = false
68
+
69
+ function swapCursor(scaleFactor) {
70
+ const want = 32 * scaleFactor
71
+ const size = CURSOR_SIZES.reduce((best, s) => (Math.abs(s - want) < Math.abs(best - want) ? s : best))
72
+ const file = path.join(__dirname, '..', 'assets', 'cursor', `cursor-${size}.cur`)
73
+ try {
74
+ // SetSystemCursor 会接管并销毁传进去的句柄,所以每种光标单独加载一份
75
+ for (const id of SWAPPED_CURSORS) {
76
+ const h = LoadImageW(0, file, IMAGE_CURSOR, 0, 0, LR_LOADFROMFILE)
77
+ if (h) { SetSystemCursor(h, id); cursorSwapped = true }
78
+ }
79
+ } catch {}
80
+ }
81
+
82
+ /** 恢复用户自己的光标;force 用在启动时,防止上次崩溃留下鲸鱼娘光标 */
83
+ function restoreCursor(force) {
84
+ if (!cursorSwapped && !force) return
85
+ try { SystemParametersInfoW(SPI_SETCURSORS, 0, 0, 0) } catch {}
86
+ cursorSwapped = false
87
+ }
88
+
89
+ const POLL_MS = 70
90
+ const HOLD_MAX_MS = 5 * 60 * 1000
91
+ const RIGHT_POLL_MS = 15
92
+ const TIMEOUT_MS = 30_000
93
+
94
+ const CONTROL_LABEL = {
95
+ 'ControlType.Document': '文档', 'ControlType.Edit': '输入框', 'ControlType.Text': '文字', 'ControlType.List': '列表',
96
+ 'ControlType.ListItem': '列表项', 'ControlType.Button': '按钮', 'ControlType.Pane': '面板', 'ControlType.Window': '窗口',
97
+ 'ControlType.Group': '分组', 'ControlType.DataItem': '数据行', 'ControlType.TreeItem': '树节点', 'ControlType.Hyperlink': '链接',
98
+ 'ControlType.Image': '图片', 'ControlType.Table': '表格', 'ControlType.Custom': '控件', 'ControlType.MenuItem': '菜单项',
99
+ }
100
+
101
+ const label = (c) => CONTROL_LABEL[c?.control] ?? '控件'
102
+
103
+ /** 截一块屏幕区域(物理像素),返回 PNG data URL */
104
+ async function captureRect(display, rect) {
105
+ const scale = display.scaleFactor
106
+ const size = { width: Math.round(display.size.width * scale), height: Math.round(display.size.height * scale) }
107
+ const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: size })
108
+ const source = sources.find(s => String(s.display_id) === String(display.id)) ?? sources[0]
109
+ if (!source) return undefined
110
+ const origin = screen.dipToScreenPoint({ x: display.bounds.x, y: display.bounds.y })
111
+ const pad = 6
112
+ const crop = {
113
+ x: Math.max(0, rect[0] - origin.x - pad),
114
+ y: Math.max(0, rect[1] - origin.y - pad),
115
+ width: Math.min(size.width, rect[2] + pad * 2),
116
+ height: Math.min(size.height, rect[3] + pad * 2),
117
+ }
118
+ crop.width = Math.min(crop.width, size.width - crop.x)
119
+ crop.height = Math.min(crop.height, size.height - crop.y)
120
+ if (crop.width < 4 || crop.height < 4) return undefined
121
+ let img = source.thumbnail.crop(crop)
122
+ if (img.getSize().width > 1600) img = img.resize({ width: 1600 })
123
+ // JPEG 比 PNG 小得多,截图只在内存里传
124
+ return `data:image/jpeg;base64,${img.toJPEG(85).toString('base64')}`
125
+ }
126
+
127
+ // QQ、网易云这类 Electron 程序关了无障碍,整个窗口只露出一个空壳控件
128
+ const CHROMIUM_SHELL = /^Chrome_(RenderWidgetHostHWND|WidgetWin_\d+)$|^Intermediate D3D Window$/
129
+ /**
130
+ * 读不到里面东西的程序:要么是 Chromium 空壳类名;要么这个控件没有子控件、没有文字接口、还几乎占满整个窗口
131
+ * (游戏、Qt、自绘界面)。普通程序里晃到的大面板一般有子控件,编辑区有文字接口,不会算进来
132
+ */
133
+ function isOpaque({ cls, rect, kids, readable, win, control }) {
134
+ if (CHROMIUM_SHELL.test(cls ?? '')) return true
135
+ if (isWholeWindow({ rect, win, control })) return true
136
+ if (kids || readable || !rect || !win) return false
137
+ return coverage(rect, win) >= 0.9
138
+ }
139
+ const coverage = (r, w) => (r[2] * r[3]) / Math.max(1, w[2] * w[3])
140
+ /** 指到的就是整个程序窗口本身(系统只给了一个框,比如 Windows Terminal):也给虚线小框 */
141
+ const isWholeWindow = ({ rect, win, control }) => control === 'ControlType.Window' && !!rect && !!win && coverage(rect, win) >= 0.98
142
+ // 空壳里再加一个跟着鼠标的小框(DIP),截图只截它;滚轮按档缩放
143
+ const FOCUS_W = 720
144
+ const FOCUS_H = 320
145
+ const FOCUS_STEP = 1.15
146
+ const FOCUS_MIN = 0.35
147
+ const FOCUS_MAX = 4
148
+ /** 鼠标为中心、不超出大框的小框(物理像素 [x, y, w, h]);大框本来就不大时 undefined */
149
+ function focusRect(rect, point, scale, zoom = 1) {
150
+ if (rect[2] <= FOCUS_W * scale * 1.4 && rect[3] <= FOCUS_H * scale * 1.4) return undefined
151
+ const w = Math.round(FOCUS_W * scale * zoom)
152
+ const h = Math.round(FOCUS_H * scale * zoom)
153
+ const cw = Math.min(w, rect[2])
154
+ const ch = Math.min(h, rect[3])
155
+ const x = Math.min(Math.max(point.x - cw / 2, rect[0]), rect[0] + rect[2] - cw)
156
+ const y = Math.min(Math.max(point.y - ch / 2, rect[1]), rect[1] + rect[3] - ch)
157
+ return [Math.round(x), Math.round(y), cw, ch]
158
+ }
159
+
160
+ /**
161
+ * 进入指认,直到左键或空格确认 / Esc 或右键 / 超时。
162
+ * @returns {Promise<{ element, image } | null>}
163
+ */
164
+ function pickNative() {
165
+ const cursor = screen.getCursorScreenPoint()
166
+ const display = screen.getDisplayNearestPoint(cursor)
167
+ const overlay = new BrowserWindow({
168
+ ...display.bounds,
169
+ show: false, frame: false, transparent: true, resizable: false, movable: false,
170
+ alwaysOnTop: true, skipTaskbar: true, focusable: false, hasShadow: false,
171
+ backgroundColor: '#00000000',
172
+ webPreferences: { preload: path.join(__dirname, 'overlay', 'preload.js'), contextIsolation: true, sandbox: false },
173
+ })
174
+ overlay.setIgnoreMouseEvents(true)
175
+ overlay.setAlwaysOnTop(true, 'screen-saver')
176
+ overlay.loadFile(path.join(__dirname, 'overlay', 'overlay.html'))
177
+ overlay.once('ready-to-show', () => overlay.showInactive())
178
+ swapCursor(display.scaleFactor)
179
+
180
+ let levelUp = 0
181
+ let zoom = 1 // 虚线小框的缩放,滚轮调
182
+ let chain = []
183
+ let point = screen.dipToScreenPoint(cursor)
184
+ let busy = false
185
+
186
+ return new Promise((resolve) => {
187
+ let finished = false
188
+ const keys = ['Space', 'Escape', '[', ']']
189
+
190
+ const poll = setInterval(async () => {
191
+ if (busy || overlay.isDestroyed()) return
192
+ busy = true
193
+ try {
194
+ point = screen.dipToScreenPoint(screen.getCursorScreenPoint())
195
+ const r = await uiaRequest(`point ${point.x} ${point.y}`, 1000)
196
+ chain = (r?.chain ?? []).filter(c => c.rect && c.rect[2] > 4 && c.rect[3] > 4)
197
+ // 只给最深那个控件标空壳(往上调范围的祖先都当普通控件)
198
+ if (chain[0] && r?.chain?.[0] && chain[0].rect === r.chain[0].rect) chain[0].opaque = isOpaque({ ...chain[0], kids: r.leafKids, readable: r.leafReadable, win: r.win })
199
+ draw()
200
+ } finally {
201
+ busy = false
202
+ }
203
+ }, POLL_MS)
204
+
205
+ let drawn = ''
206
+ function draw() {
207
+ if (finished || overlay.isDestroyed()) return
208
+ const c = chain[Math.min(levelUp, chain.length - 1)]
209
+ // 没变就不重发,免得过渡动画一直被打断
210
+ const key = c ? `${c.rect.join(',')}|${c.name ?? ''}|${c.opaque ? `${point.x},${point.y},${zoom}` : ''}` : ''
211
+ if (key === drawn) return
212
+ drawn = key
213
+ if (!c) return overlay.webContents.send('highlight', null)
214
+ const toLocal = (r) => {
215
+ const dip = screen.screenToDipRect(null, { x: r[0], y: r[1], width: r[2], height: r[3] })
216
+ return { x: dip.x - display.bounds.x, y: dip.y - display.bounds.y, width: dip.width, height: dip.height }
217
+ }
218
+ const f = c.opaque ? focusRect(c.rect, point, display.scaleFactor, zoom) : undefined
219
+ overlay.webContents.send('highlight', {
220
+ ...toLocal(c.rect),
221
+ label: `${label(c)}${c.name ? `:${c.name}` : ''}`,
222
+ focus: f ? toLocal(f) : null,
223
+ })
224
+ }
225
+
226
+ // 左键选中、右键退出。优先用钩子把左右键整个吃掉;钩子装不上时退回老办法(空格选中):
227
+ // 看到右键按下就让遮罩接住鼠标(下面的程序已经收到按下,有的程序还是会弹菜单)
228
+ // 有虚线小框时滚轮调它的大小(往上滚变大),指认期间本来就接管了鼠标
229
+ const focusShown = () => { const c = chain[Math.min(levelUp, chain.length - 1)]; return !!c?.opaque && !!focusRect(c.rect, point, display.scaleFactor) }
230
+ const onWheel = (dir) => {
231
+ zoom = Math.min(FOCUS_MAX, Math.max(FOCUS_MIN, zoom * (dir > 0 ? FOCUS_STEP : 1 / FOCUS_STEP)))
232
+ draw()
233
+ }
234
+ const unhook = hookButtons({ onLeftUp: () => finish(true), onRightUp: () => finish(false), onWheel, wantWheel: () => !finished && focusShown() })
235
+ let rightDown = false
236
+ const rightPoll = unhook ? undefined : setInterval(() => {
237
+ const down = (GetAsyncKeyState(VK_RBUTTON) & 0x8000) !== 0
238
+ if (down && !rightDown) {
239
+ rightDown = true
240
+ if (!overlay.isDestroyed()) overlay.setIgnoreMouseEvents(false)
241
+ } else if (!down && rightDown) {
242
+ finish(false)
243
+ }
244
+ }, RIGHT_POLL_MS)
245
+
246
+ async function finish(confirm) {
247
+ if (finished) return
248
+ finished = true
249
+ restoreCursor()
250
+ clearInterval(poll)
251
+ clearInterval(rightPoll)
252
+ unhook?.()
253
+ clearTimeout(timer)
254
+ for (const k of keys) globalShortcut.unregister(k)
255
+ const release = () => { clearTimeout(expire); if (!overlay.isDestroyed()) overlay.destroy() }
256
+ // 来源程序的窗口挪了,留着的高亮框跟着挪(DIP)
257
+ release.shift = (dx, dy) => { if (!overlay.isDestroyed()) overlay.webContents.send('shift', { dx, dy }) }
258
+ // 万一没人来撤,几分钟后自己撤掉,免得框一直挂在屏幕上
259
+ const expire = setTimeout(release, HOLD_MAX_MS)
260
+ if (!confirm || !chain.length) { release(); return resolve(null) }
261
+ // 遮罩先藏起来再查再截,免得把高亮框截进去;截完再拿出来,只留高亮框,卡片关掉时撤掉
262
+ if (!overlay.isDestroyed()) overlay.hide()
263
+ const level = Math.min(levelUp, chain.length - 1)
264
+ await new Promise(r => setTimeout(r, 150))
265
+ const detail = await uiaRequest(`element ${point.x} ${point.y} ${level}`, 3000)
266
+ if (!detail?.ok) { release(); return resolve(detail?.isPassword ? { blocked: '指的是密码框' } : null) }
267
+ // 空壳程序(QQ、网易云):截图只截跟着鼠标的小框,能读到元素的程序照旧截整块
268
+ const whole = isWholeWindow({ rect: detail.rect, win: detail.win, control: detail.control })
269
+ const opaque = isOpaque({ cls: detail.className, rect: detail.rect, kids: detail.kids, readable: detail.readable, win: detail.win, control: detail.control }) && (whole || !(detail.text ?? '').trim())
270
+ const focus = detail.rect && opaque ? focusRect(detail.rect, point, display.scaleFactor, zoom) : undefined
271
+ const image = detail.rect ? await captureRect(display, focus ?? detail.rect).catch(() => undefined) : undefined
272
+ // 第二张:空壳程序是整个大框;能读到元素的程序是整个窗口,让模型看到周围还有什么(比如文件夹里别的文件)
273
+ const wideRect = focus ? detail.rect : detail.win && detail.win.join(',') !== (detail.rect ?? []).join(',') ? detail.win : undefined
274
+ const wide = wideRect ? await captureRect(display, wideRect).catch(() => undefined) : undefined
275
+ if (!overlay.isDestroyed()) {
276
+ overlay.webContents.send('hold')
277
+ // 和卡片一样挂到来源程序下面:在那个程序上面,别的程序切到前面就把它盖住;
278
+ // 来源程序找不到时才退回置顶。后弹出的卡片挂在同一个程序下,会在它上面
279
+ const owner = foregroundAddress()
280
+ overlay.setAlwaysOnTop(false)
281
+ try { if (owner) setOwner(overlay.getNativeWindowHandle().readBigUInt64LE(0), owner); else overlay.setAlwaysOnTop(true, 'floating') } catch { overlay.setAlwaysOnTop(true, 'floating') }
282
+ overlay.setIgnoreMouseEvents(true)
283
+ overlay.showInactive()
284
+ }
285
+ resolve({
286
+ release,
287
+ element: {
288
+ kind: 'ui-element',
289
+ site: '',
290
+ tag: detail.control,
291
+ fields: { 控件: label(detail), 名称: (detail.name ?? '').slice(0, 120), 类名: detail.className ?? '' },
292
+ page: {},
293
+ text: detail.text ?? '',
294
+ links: [],
295
+ images: [],
296
+ },
297
+ image,
298
+ wide,
299
+ focused: !!focus,
300
+ opaque,
301
+ // 调试开关打开时主进程写进日志,看各个程序被归成哪类
302
+ diag: { cls: detail.className ?? '', control: detail.control ?? '', kids: !!detail.kids, readable: !!detail.readable, cover: detail.rect && detail.win ? Math.round(coverage(detail.rect, detail.win) * 100) : null, text: (detail.text ?? '').trim().length, opaque },
303
+ })
304
+ }
305
+
306
+ const timer = setTimeout(() => finish(false), TIMEOUT_MS)
307
+ globalShortcut.register('Space', () => finish(true))
308
+ globalShortcut.register('Escape', () => finish(false))
309
+ globalShortcut.register('[', () => { levelUp = Math.max(0, levelUp - 1); draw() })
310
+ globalShortcut.register(']', () => { levelUp = Math.min(chain.length - 1, levelUp + 1); draw() })
311
+ })
312
+ }
313
+
314
+ module.exports = { pickNative, restoreCursor }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file