@pointylab/dsh 0.0.0-stage → 0.4.3

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 +1252 -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,1252 @@
1
+ // pointy 主进程:透明置顶窗口 + 托盘 + 划词卡片 + 本地 WebSocket 服务(DSH 插件和浏览器扩展都连这里)
2
+ const { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, Tray, nativeImage, screen, shell } = require('electron')
3
+ const crypto = require('node:crypto')
4
+ const fs = require('node:fs')
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const { WebSocketServer } = require('ws')
8
+ const { isFullscreenActive, isTopmost, assertTopmost, yieldTopmost, shellPopupOpen, foregroundAddress, allowAnyForeground, setOwner, windowOrigin } = require('./fullscreen')
9
+ const { grabSelection, foreground, blockedReason, elevatedUnderCursor, BROWSERS, stopSelectionHelper } = require('./selection')
10
+ const { startShakeDetector } = require('./shake')
11
+ const { pickNative, restoreCursor } = require('./uipick')
12
+
13
+ const PORT = Number(process.env.POINTY_PORT) || 47321
14
+ const TOKEN_DIR = path.join(os.homedir(), '.pointy')
15
+ const TOKEN_FILE = path.join(TOKEN_DIR, 'token')
16
+ const SETTINGS_FILE = path.join(TOKEN_DIR, 'settings.json')
17
+ // 旧版插件按它拉起Pointy;现在插件自带Pointy,这个文件启动时删掉
18
+ const LAUNCH_FILE = path.join(TOKEN_DIR, 'launch.json')
19
+ // 由 DSH 插件拉起(插件形态):DSH 关了Pointy也跟着退
20
+ const MANAGED = process.env.POINTY_MANAGED === '1'
21
+ // 给用户"加载已解压的扩展程序"用的固定位置;插件升级换目录也不影响已装的扩展
22
+ const USER_EXTENSION_DIR = path.join(TOKEN_DIR, 'extension')
23
+ const BUNDLED_EXTENSION_DIR = path.join(__dirname, '..', 'extension')
24
+ // 配对过的浏览器扩展:id -> token
25
+ const EXTENSIONS_FILE = path.join(TOKEN_DIR, 'extensions.json')
26
+ const SPRITES = path.join(__dirname, '..', 'assets', 'sprites')
27
+ // 右下角的Pointy窗口不再显示:卡片左边的看板娘取代了它。窗口对象还留着(很多地方拿它的句柄),只是不加载、不显示
28
+ const SHOW_PET = false
29
+ const TRAY_ICON = path.join(__dirname, '..', 'assets', 'tray.png')
30
+ // 人物原始尺寸(100%)。窗口 = 人物 + 头顶给气泡留的地方;人物在窗口底部正中,
31
+ // 宽度按"气泡贴着屏幕边往回挪"留足两倍气泡宽,再加阴影余量。透明处点击穿透,宽一点不碍事
32
+ const CHAR_W = 200
33
+ const CHAR_H = 240
34
+ const BUBBLE_MAX_W = 260
35
+ const BUBBLE_ROOM_H = 210
36
+ const BUBBLE_PAD = 24
37
+ const SCALE_MIN = 0.5
38
+ const SCALE_MAX = 1.2
39
+ const SIZE_PRESETS = [['小', 0.6], ['中', 0.8], ['大', 1]]
40
+ // 卡片本身 420 宽,四周各留 18px 给阴影
41
+ // 卡片窗口左边多留的看板娘位置,和 card.css 的 --card-left 对应(68 - 18)
42
+ const MASCOT_ROOM = 50
43
+ const CARD_W = 520 + MASCOT_ROOM
44
+ const CARD_MIN_W = 360 + MASCOT_ROOM
45
+ const CARD_MIN_H = 200
46
+ const CARD_H_ASK = 300
47
+ const CARD_H_ANSWER = 560
48
+ // 主进程出错只记到这里,不弹 Electron 的系统错误框(用户可能正在玩全屏游戏)
49
+ const ERROR_LOG = path.join(TOKEN_DIR, 'error.log')
50
+ const ERROR_LOG_MAX = 256 * 1024
51
+ function logError(kind, err) {
52
+ try {
53
+ fs.mkdirSync(TOKEN_DIR, { recursive: true })
54
+ if ((fs.statSync(ERROR_LOG, { throwIfNoEntry: false })?.size ?? 0) > ERROR_LOG_MAX) fs.rmSync(ERROR_LOG)
55
+ fs.appendFileSync(ERROR_LOG, `${new Date().toISOString()} [${kind}] ${err?.stack ?? err}\n`)
56
+ } catch {}
57
+ }
58
+ process.on('uncaughtException', (err) => logError('uncaught', err))
59
+ process.on('unhandledRejection', (err) => logError('unhandled-rejection', err))
60
+ // 卡片和Pointy窗口里的报错、渲染进程崩掉,也只记日志
61
+ app.on('web-contents-created', (_e, wc) => {
62
+ wc.on('console-message', (e) => { if (e.level === 'error') logError('renderer', `${e.message} (${e.sourceId}:${e.lineNumber})`) })
63
+ wc.on('render-process-gone', (_e, details) => logError('render-process-gone', JSON.stringify(details)))
64
+ wc.on('preload-error', (_e, preloadPath, err) => logError('preload', `${preloadPath}: ${err?.stack ?? err}`))
65
+ })
66
+ const FULLSCREEN_POLL_MS = 500
67
+ // 置顶维护单独跑快一点:系统弹出层一出来就得让开
68
+ const TOPMOST_POLL_MS = 150
69
+ const CLICK_POLL_MS = 40
70
+ const HOME_MARGIN_X = 16
71
+ const HOME_MARGIN_Y = 24
72
+ // 点击穿透:alpha 超过这个才算立绘本体,轮廓再外扩几个 DIP
73
+ const MASK_ALPHA = 24
74
+ const MASK_GROW = 3
75
+ // DSH 断开这么久没回来,就当 DSH 退出了
76
+ const QUIT_WITH_DSH_GRACE_MS = 10_000
77
+ const PAIR_ASK_TIMEOUT_MS = 60_000
78
+ const EXTENSION_GRAB_TIMEOUT_MS = 900
79
+ // 调试用:设了这个目录,每次状态变化都把窗口截一张图存进去
80
+ const CAPTURE_DIR = process.env.POINTY_CAPTURE
81
+ // 测试用:卡片照常渲染但从不显示(全屏游戏时也能测),并把 openCard 挂到 global 上给调试器调用
82
+ const TEST_HIDDEN = process.env.POINTY_TEST_HIDDEN === '1'
83
+
84
+ // Pointy由 DSH 插件拉起、随 DSH 退出,没有开机自启
85
+ const DEFAULT_SETTINGS = { dnd: false, onTop: true, shake: true, shakeLevel: 'medium', scale: 0.6, welcomed: false }
86
+ const PICK_TIMEOUT_MS = 60_000
87
+ const PICK_START_TIMEOUT_MS = 4000
88
+
89
+ /** 正在指认就不响应新的晃动;卡了太久(哪条路没收尾)就当它结束了,记一笔 */
90
+ function pickBusy() {
91
+ if (!picking) return false
92
+ if (Date.now() - (picking.at ?? 0) < PICK_TIMEOUT_MS + 10_000) return true
93
+ logError('pick-stuck', `cleared a stale pick ${picking.reqId}`)
94
+ picking = undefined
95
+ return false
96
+ }
97
+ // 全屏时窗口整个藏起来,所以这里可以用比 floating 高的级别,不会压到全屏游戏上
98
+ const TOP_LEVEL = 'pop-up-menu'
99
+
100
+ let win
101
+ let card
102
+ let tray
103
+ let plugin // 已通过认证的插件连接
104
+ const extensions = new Map() // 扩展 id -> { ws, browser }
105
+ let everConnected = false
106
+ let quitTimer
107
+ let homePos // 跑到前面之前的位置(人物左上角)
108
+ let petPos // 人物左上角的屏幕坐标(DIP)
109
+ let sentLayout = ''
110
+ let fullscreen = false
111
+ let pendingApprovals = 0
112
+ let wantFront = false
113
+ let dshUi = { kind: 'desktop' } // 插件报来的:DSH 是桌面版,还是浏览器里的 web 版(origin 是它的地址)
114
+ let pairAsking = false // 正在问用户要不要让某个扩展连进来
115
+ const deniedExtensions = new Set() // 这次运行里用户点过"不允许"的扩展(按 origin),不再自动弹
116
+ let settings = loadSettings()
117
+
118
+ // 测试实例:Pointy窗口、托盘、快捷键都不出来,只留服务和隐藏的卡片
119
+ const suppressed = () => fullscreen || settings.dnd || TEST_HIDDEN
120
+
121
+ function loadSettings() {
122
+ try { return { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) } } catch { return { ...DEFAULT_SETTINGS } }
123
+ }
124
+
125
+ function saveSettings(patch) {
126
+ settings = { ...settings, ...patch }
127
+ fs.mkdirSync(TOKEN_DIR, { recursive: true })
128
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2))
129
+ }
130
+
131
+ /** extension/manifest.json 里的版本号;和正在跑的扩展报上来的不一样,说明磁盘上改过了 */
132
+ function extensionVersionOnDisk() {
133
+ try { return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'extension', 'manifest.json'), 'utf8')).version } catch { return undefined }
134
+ }
135
+
136
+ function loadExtensionTokens() {
137
+ try { return JSON.parse(fs.readFileSync(EXTENSIONS_FILE, 'utf8')) } catch { return {} }
138
+ }
139
+
140
+ function saveExtensionToken(id, token) {
141
+ const all = loadExtensionTokens()
142
+ all[id] = token
143
+ fs.mkdirSync(TOKEN_DIR, { recursive: true })
144
+ fs.writeFileSync(EXTENSIONS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 })
145
+ }
146
+
147
+ /** 旧版留下的:开机自启项和 launch.json。现在只由 DSH 插件拉起,这两样清掉 */
148
+ function dropOldAutostart() {
149
+ try {
150
+ const item = { path: process.execPath, args: app.isPackaged ? [] : [app.getAppPath()] }
151
+ if (app.getLoginItemSettings(item).openAtLogin) app.setLoginItemSettings({ ...item, openAtLogin: false })
152
+ } catch (err) { logError('login-item', err) }
153
+ fs.rmSync(LAUNCH_FILE, { force: true })
154
+ }
155
+
156
+ /** 自带的扩展抄一份到 ~/.pointy/extension,用户从这里加载;版本号变了才覆盖 */
157
+ function syncUserExtension() {
158
+ const version = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')).version } catch { return undefined } }
159
+ const bundled = version(BUNDLED_EXTENSION_DIR)
160
+ if (!bundled || path.resolve(BUNDLED_EXTENSION_DIR) === path.resolve(USER_EXTENSION_DIR)) return
161
+ if (version(USER_EXTENSION_DIR) === bundled) return
162
+ try { fs.cpSync(BUNDLED_EXTENSION_DIR, USER_EXTENSION_DIR, { recursive: true, force: true }) } catch (err) { logError('extension-copy', err) }
163
+ }
164
+
165
+ function loadToken() {
166
+ try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim() } catch {}
167
+ fs.mkdirSync(TOKEN_DIR, { recursive: true })
168
+ const token = crypto.randomBytes(32).toString('hex')
169
+ fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 })
170
+ return token
171
+ }
172
+
173
+ const hmac = (token, data) => crypto.createHmac('sha256', token).update(data).digest('hex')
174
+ const safeEqual = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
175
+ const toRenderer = (msg) => win?.webContents.send('dsh', msg)
176
+ // 所有活着的卡片(包括收起到后台继续跑的);每张卡片自己按 askId / reqId 认领消息
177
+ const cards = new Set()
178
+ const toCard = (msg) => { for (const c of cards) c.webContents.send('dsh', msg) }
179
+
180
+ // 插件发来的消息:划词相关的给卡片,其余给Pointy
181
+ let pluginVersion // 插件连上时报的版本,托盘里显示,方便看出 DSH 加载的是不是新版
182
+
183
+ function routePluginMessage(msg) {
184
+ if (msg.type === 'ready') { pluginVersion = msg.plugin; updateTray() }
185
+ if (msg.type === 'ui') { dshUi = { kind: msg.kind === 'web' ? 'web' : 'desktop', origin: typeof msg.origin === 'string' ? msg.origin : undefined }; logError('dsh-ui', JSON.stringify(dshUi)) }
186
+ if (msg.type === 'ask-done' || msg.type === 'ask-error' || msg.type === 'state' || msg.type === 'title') pillFromPlugin(msg)
187
+ if (typeof msg.type === 'string' && (msg.type.startsWith('ask-') || msg.type === 'sessions' || msg.type === 'suggestions' || msg.type === 'title')) toCard(msg)
188
+ else toRenderer(msg)
189
+ if (!SHOW_PET) {
190
+ if (msg.type === 'approval') {
191
+ pendingApprovals++
192
+ notify('DSH 有个操作等你批准', '点这里打开 DSH', () => openDsh(msg.sessionId))
193
+ updateTray()
194
+ } else if (msg.type === 'approval-closed') {
195
+ pendingApprovals = Math.max(0, pendingApprovals - 1)
196
+ updateTray()
197
+ }
198
+ }
199
+ }
200
+
201
+ /** 托盘气泡通知(Pointy不显示以后,收起的任务做完、DSH 要批准都靠它) */
202
+ let notifyClick
203
+ function notify(title, content, onClick) {
204
+ if (!tray || suppressed()) return
205
+ notifyClick = onClick
206
+ try { tray.displayBalloon({ title, content, iconType: 'none', icon: nativeImage.createFromPath(TRAY_ICON), noSound: true }) } catch (err) { logError('balloon', err) }
207
+ }
208
+
209
+ function onPluginAuthed(ws) {
210
+ if (plugin && plugin !== ws) plugin.close()
211
+ plugin = ws
212
+ everConnected = true
213
+ clearTimeout(quitTimer)
214
+ toRenderer({ type: 'link', connected: true })
215
+ updateTray()
216
+ }
217
+
218
+ function onPluginClosed(ws) {
219
+ if (plugin !== ws) return
220
+ plugin = undefined
221
+ pluginVersion = undefined
222
+ toRenderer({ type: 'link', connected: false })
223
+ updateTray()
224
+ // 插件重载会马上重连;过了宽限期还没回来,就是 DSH 关了
225
+ clearTimeout(quitTimer)
226
+ if (everConnected && MANAGED) quitTimer = setTimeout(() => { if (!plugin) app.quit() }, QUIT_WITH_DSH_GRACE_MS)
227
+ }
228
+
229
+ const pendingGrabs = new Map()
230
+ let picking // { reqId, resolve } 指认进行中
231
+ let lastPickTab // 扩展回报的:上一次指认脚本注入到了哪个标签页
232
+
233
+ function onExtensionMessage(msg) {
234
+ if (msg.type === 'grab-result') {
235
+ pendingGrabs.get(msg.reqId)?.(msg)
236
+ pendingGrabs.delete(msg.reqId)
237
+ } else if (msg.type === 'pick-started') {
238
+ lastPickTab = { title: msg.tabTitle, url: msg.tabUrl }
239
+ if (picking?.reqId === msg.reqId) picking.started = true
240
+ } else if (msg.type === 'picked' || msg.type === 'pick-cancel' || msg.type === 'pick-unavailable') {
241
+ if (picking?.reqId === msg.reqId) picking.resolve(msg)
242
+ } else if (msg.type === 'selection') {
243
+ // 扩展右键菜单直接发来的选区
244
+ openCard({ source: { app: msg.browser ?? 'browser', title: msg.pageTitle ?? '' }, method: 'extension', ...msg })
245
+ }
246
+ }
247
+
248
+ function startServer(token) {
249
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: PORT, maxPayload: 8 << 20 })
250
+ wss.on('error', (err) => toRenderer({ type: 'server-error', error: String(err.message) }))
251
+ wss.on('connection', (ws, req) => {
252
+ const nonce = crypto.randomBytes(16).toString('hex')
253
+ let role // 'plugin' | 'extension'
254
+ let extId
255
+ ws.send(JSON.stringify({ type: 'challenge', nonce }))
256
+ const kick = setTimeout(() => { if (!role) ws.close() }, 10_000)
257
+ ws.on('message', (raw) => {
258
+ let msg
259
+ try { msg = JSON.parse(String(raw)) } catch { return }
260
+ if (role === 'plugin') return routePluginMessage(msg)
261
+ if (role === 'extension') return onExtensionMessage(msg)
262
+ // 配对:还没配过的扩展连上来要一个自己的 token,Pointy 弹窗问用户允不允许。
263
+ // 只认浏览器扩展发起的连接(网页的 Origin 是 http(s),伪造不了)
264
+ if (msg.type === 'pair-request') {
265
+ const origin = String(req.headers.origin ?? '')
266
+ const send = (m) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m)) }
267
+ if (!/^(chrome|moz)-extension:\/\/[a-z0-9-]+$/i.test(origin)) return ws.close()
268
+ if (deniedExtensions.has(origin) && !msg.manual) { send({ type: 'pair-denied' }); return ws.close() }
269
+ if (pairAsking || TEST_HIDDEN && !global.pointyTest?.allowPairAsk) { send({ type: 'pair-busy' }); return ws.close() }
270
+ clearTimeout(kick)
271
+ pairAsking = true
272
+ // 等用户点的时候隔一会儿发一条,扩展的 service worker 收到消息才不会被浏览器停掉
273
+ const keep = setInterval(() => send({ type: 'pair-wait' }), 10_000)
274
+ const browser = msg.browser === 'edge' ? 'Edge' : 'Chrome'
275
+ const asked = showNotice({
276
+ title: `${browser} 里的 Pointy 扩展想连接`,
277
+ body: ['允许后,在浏览器里晃一下鼠标就能指认网页上的具体内容,右键菜单也能「问 Pointy」。'],
278
+ buttons: [{ id: 'deny', label: '不允许' }, { id: 'allow', label: '允许', primary: true }],
279
+ timeoutMs: PAIR_ASK_TIMEOUT_MS,
280
+ })
281
+ // 扩展那边先断了(浏览器关了、扩展被停了):弹窗也收掉
282
+ const cancel = noticeDone
283
+ ws.once('close', () => cancel?.(null))
284
+ asked.then((choice) => {
285
+ clearInterval(keep)
286
+ pairAsking = false
287
+ if (choice !== 'allow') {
288
+ if (choice === 'deny') deniedExtensions.add(origin)
289
+ send({ type: 'pair-denied' })
290
+ return ws.close()
291
+ }
292
+ deniedExtensions.delete(origin)
293
+ const id = crypto.randomUUID()
294
+ const extToken = crypto.randomBytes(32).toString('hex')
295
+ saveExtensionToken(id, extToken)
296
+ send({ type: 'paired', id, token: extToken })
297
+ ws.close()
298
+ })
299
+ return
300
+ }
301
+ if (msg.type !== 'hello' || typeof msg.nonce !== 'string') return ws.close()
302
+ if (msg.role === 'extension') {
303
+ const extToken = loadExtensionTokens()[msg.id]
304
+ if (!extToken || !safeEqual(msg.proof, hmac(extToken, `ext|${nonce}`))) return ws.close()
305
+ role = 'extension'
306
+ extId = msg.id
307
+ clearTimeout(kick)
308
+ ws.send(JSON.stringify({ type: 'hello-ok', proof: hmac(extToken, `pet|${msg.nonce}`) }))
309
+ extensions.get(extId)?.ws.close()
310
+ extensions.set(extId, { ws, browser: String(msg.browser ?? '') })
311
+ updateTray()
312
+ // 扩展是从 extension/ 文件夹直接加载的:磁盘上的版本号变了就让它自己重载(只对会处理 reload 的版本有效)
313
+ const onDisk = extensionVersionOnDisk()
314
+ if (onDisk && msg.version && msg.version !== onDisk) ws.send(JSON.stringify({ type: 'reload' }))
315
+ return
316
+ }
317
+ if (!safeEqual(msg.proof, hmac(token, `plugin|${nonce}`))) return ws.close()
318
+ role = 'plugin'
319
+ clearTimeout(kick)
320
+ ws.send(JSON.stringify({ type: 'hello-ok', proof: hmac(token, `pet|${msg.nonce}`) }))
321
+ onPluginAuthed(ws)
322
+ })
323
+ ws.on('close', () => {
324
+ clearTimeout(kick)
325
+ if (role === 'plugin') onPluginClosed(ws)
326
+ if (role === 'extension' && extensions.get(extId)?.ws === ws) { extensions.delete(extId); updateTray() }
327
+ })
328
+ })
329
+ }
330
+
331
+ /** 前台是浏览器时,向对应浏览器里配对过的扩展要选区 */
332
+ function extensionGrabFor(exe) {
333
+ if (!BROWSERS.has(exe) || extensions.size === 0) return undefined
334
+ const want = exe === 'msedge.exe' ? 'edge' : 'chrome'
335
+ const ext = [...extensions.values()].find(e => e.browser === want) ?? [...extensions.values()][0]
336
+ return () => new Promise((resolve, reject) => {
337
+ const reqId = crypto.randomUUID()
338
+ const timer = setTimeout(() => { pendingGrabs.delete(reqId); reject(new Error('timeout')) }, EXTENSION_GRAB_TIMEOUT_MS)
339
+ pendingGrabs.set(reqId, (msg) => { clearTimeout(timer); resolve(msg) })
340
+ ext.ws.send(JSON.stringify({ type: 'grab', reqId }))
341
+ })
342
+ }
343
+
344
+ const petScale = () => Math.min(SCALE_MAX, Math.max(SCALE_MIN, Number(settings.scale) || DEFAULT_SETTINGS.scale))
345
+
346
+ function petGeometry() {
347
+ const s = petScale()
348
+ const cw = Math.round(CHAR_W * s)
349
+ const ch = Math.round(CHAR_H * s)
350
+ return { cw, ch, w: 2 * (BUBBLE_MAX_W + BUBBLE_PAD), h: ch + BUBBLE_ROOM_H }
351
+ }
352
+
353
+ // 右下角,但离任务栏留一段;离屏幕右边留到最宽的气泡也能在头顶正中放下。返回人物左上角
354
+ function homePosition() {
355
+ const { workArea } = screen.getPrimaryDisplay()
356
+ const { cw, ch } = petGeometry()
357
+ const marginX = Math.max(HOME_MARGIN_X, Math.ceil((BUBBLE_MAX_W - cw) / 2) + HOME_MARGIN_X)
358
+ return [workArea.x + workArea.width - cw - marginX, workArea.y + workArea.height - ch - HOME_MARGIN_Y]
359
+ }
360
+
361
+ // 按人物位置摆窗口(人物在窗口底部正中)。
362
+ // 只用 setBounds 带上尺寸:Windows 缩放 150% 时反复 setPosition 会让窗口一点点变大
363
+ function placePet(x, y) {
364
+ petPos = [Math.round(x), Math.round(y)]
365
+ const g = petGeometry()
366
+ win.setBounds({ x: petPos[0] - Math.round((g.w - g.cw) / 2), y: petPos[1] - (g.h - g.ch), width: g.w, height: g.h })
367
+ sendLayout()
368
+ }
369
+
370
+ function sendLayout(force = false) {
371
+ const layout = { type: 'layout', scale: petScale() }
372
+ const key = JSON.stringify(layout)
373
+ if (!force && key === sentLayout) return
374
+ sentLayout = key
375
+ toRenderer(layout)
376
+ }
377
+
378
+ // 换大小时人物脚底中点不动
379
+ let saveScaleTimer
380
+ function setScale(scale, { persist = 'now' } = {}) {
381
+ const before = petGeometry()
382
+ settings.scale = Math.round(Math.min(SCALE_MAX, Math.max(SCALE_MIN, scale)) * 100) / 100
383
+ const after = petGeometry()
384
+ placePet(petPos[0] + (before.cw - after.cw) / 2, petPos[1] + (before.ch - after.ch))
385
+ clearTimeout(saveScaleTimer)
386
+ if (persist === 'now') saveSettings({ scale: settings.scale })
387
+ else saveScaleTimer = setTimeout(() => saveSettings({ scale: settings.scale }), 600)
388
+ updateTray()
389
+ }
390
+
391
+ function moveToFront() {
392
+ if (!homePos) homePos = petPos
393
+ const { workArea } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
394
+ const { cw, ch } = petGeometry()
395
+ placePet(workArea.x + (workArea.width - cw) / 2, workArea.y + workArea.height - ch - 60)
396
+ win.moveTop()
397
+ }
398
+
399
+ // 全屏或勿扰时整个窗口藏起来;恢复时只 showInactive,从不抢焦点
400
+ function applyVisibility() {
401
+ if (!win) return
402
+ toRenderer({ type: 'suppressed', on: suppressed() })
403
+ if (suppressed()) {
404
+ if (win.isVisible()) win.hide()
405
+ // 卡片先藏起来,退出全屏再拿出来;指认留着的高亮框直接撤掉,免得盖在游戏上
406
+ if (card?.isVisible()) { card.hide(); card.hiddenBySuppress = true }
407
+ card?.releasePick?.()
408
+ if (card) card.releasePick = undefined
409
+ if (pillWin?.isVisible()) pillWin.hide()
410
+ } else if (SHOW_PET && !win.isVisible()) {
411
+ win.showInactive()
412
+ applyOnTop()
413
+ if (wantFront) moveToFront()
414
+ // 全屏期间到点的待办,回来再说
415
+ }
416
+ if (!suppressed() && card && !card.isDestroyed() && card.hiddenBySuppress) {
417
+ card.hiddenBySuppress = false
418
+ card.showInactive()
419
+ }
420
+ if (!suppressed() && pillWin && !pillWin.isDestroyed() && !pillWin.isVisible()) pillWin.showInactive()
421
+ updateTray()
422
+ }
423
+
424
+ function applyOnTop() {
425
+ win.setAlwaysOnTop(settings.onTop, TOP_LEVEL)
426
+ if (settings.onTop) win.moveTop()
427
+ }
428
+
429
+ const winHandle = () => win.getNativeWindowHandle().readBigUInt64LE(0)
430
+ let lastForeground = 0n
431
+
432
+ let yielded = false // 正让位给系统弹出层
433
+
434
+ // 每次轮询:TOPMOST 掉了,或者前台换了窗口,就重新压到最上层(SWP_NOACTIVATE,不抢焦点)。
435
+ // 托盘溢出面板、右键菜单、开始菜单这些系统弹出层开着时反过来,退到它们下面,关掉后再回来
436
+ function keepOnTop() {
437
+ if (!win || !settings.onTop || suppressed() || !win.isVisible()) return
438
+ const hwnd = winHandle()
439
+ if (shellPopupOpen()) {
440
+ if (!yielded) yieldTopmost(hwnd)
441
+ yielded = true
442
+ return
443
+ }
444
+ const fg = foregroundAddress()
445
+ if (yielded || !isTopmost(hwnd) || fg !== lastForeground) assertTopmost(hwnd)
446
+ yielded = false
447
+ lastForeground = fg
448
+ }
449
+
450
+ // ---------- 点击穿透 ----------
451
+ // 窗口里透明的地方不接鼠标,点下去直接落到后面的窗口;只有立绘不透明的地方、气泡、角标接。
452
+ // 光标由主进程轮询(拖文件进来时渲染进程收不到鼠标事件),各元素的位置由渲染进程报上来
453
+ let petLayout // { character, bubble, badge }:窗口内坐标(DIP),不显示的是 null
454
+ let petDragging = false
455
+ let clickable = true
456
+ let petMask // { w, h, bits }:立绘框里每个 DIP 算不算"人"
457
+
458
+ const inRect = (r, x, y) => !!r && x >= r.x && y >= r.y && x < r.x + r.width && y < r.y + r.height
459
+
460
+ /** 所有帧的轮廓取并集(按 object-fit: contain 缩放到立绘框),再外扩几像素,边缘好点中 */
461
+ function buildPetMask(w, h) {
462
+ const raw = new Uint8Array(w * h)
463
+ const frames = fs.existsSync(SPRITES) ? fs.readdirSync(SPRITES).filter(f => f.endsWith('.png')) : []
464
+ for (const f of frames) {
465
+ const img = nativeImage.createFromPath(path.join(SPRITES, f))
466
+ const { width: nw, height: nh } = img.getSize()
467
+ const bmp = img.toBitmap()
468
+ const s = Math.min(w / nw, h / nh)
469
+ const ox = (w - nw * s) / 2
470
+ const oy = (h - nh * s) / 2
471
+ for (let y = 0; y < h; y++) {
472
+ for (let x = 0; x < w; x++) {
473
+ const sx = Math.floor((x + 0.5 - ox) / s)
474
+ const sy = Math.floor((y + 0.5 - oy) / s)
475
+ if (sx >= 0 && sy >= 0 && sx < nw && sy < nh && bmp[(sy * nw + sx) * 4 + 3] > MASK_ALPHA) raw[y * w + x] = 1
476
+ }
477
+ }
478
+ }
479
+ const bits = new Uint8Array(w * h)
480
+ for (let y = 0; y < h; y++) {
481
+ for (let x = 0; x < w; x++) {
482
+ if (!raw[y * w + x]) continue
483
+ for (let dy = -MASK_GROW; dy <= MASK_GROW; dy++) {
484
+ for (let dx = -MASK_GROW; dx <= MASK_GROW; dx++) {
485
+ const X = x + dx
486
+ const Y = y + dy
487
+ if (X >= 0 && Y >= 0 && X < w && Y < h) bits[Y * w + X] = 1
488
+ }
489
+ }
490
+ }
491
+ }
492
+ return { w, h, bits }
493
+ }
494
+
495
+ function cursorOnPet() {
496
+ if (!petLayout) return true // 布局还没报上来,先整块接鼠标
497
+ const c = screen.getCursorScreenPoint()
498
+ const b = win.getBounds()
499
+ const x = c.x - b.x
500
+ const y = c.y - b.y
501
+ if (inRect(petLayout.bubble, x, y) || inRect(petLayout.badge, x, y)) return true
502
+ const r = petLayout.character
503
+ if (!inRect(r, x, y)) return false
504
+ const w = Math.round(r.width)
505
+ const h = Math.round(r.height)
506
+ if (petMask?.w !== w || petMask?.h !== h) petMask = buildPetMask(w, h)
507
+ const px = Math.min(w - 1, Math.floor(x - r.x))
508
+ const py = Math.min(h - 1, Math.floor(y - r.y))
509
+ return petMask.bits[py * w + px] === 1
510
+ }
511
+
512
+ function updateClickThrough() {
513
+ if (!win || win.isDestroyed() || !win.isVisible()) return
514
+ const want = petDragging || cursorOnPet()
515
+ if (want === clickable) return
516
+ clickable = want
517
+ win.setIgnoreMouseEvents(!want)
518
+ }
519
+
520
+ ipcMain.on('pet-layout', (_e, layout) => {
521
+ if (layout && typeof layout === 'object' && layout.character) petLayout = layout
522
+ })
523
+ ipcMain.on('pet-scale-by', (_e, delta) => {
524
+ if (win && Number.isFinite(delta)) setScale(petScale() + delta, { persist: 'later' })
525
+ })
526
+ ipcMain.on('pet-dragging', (_e, on) => {
527
+ petDragging = !!on
528
+ updateClickThrough()
529
+ })
530
+
531
+ function createWindow() {
532
+ const [x, y] = homePosition()
533
+ const g = petGeometry()
534
+ win = new BrowserWindow({
535
+ x, y, width: g.w, height: g.h,
536
+ show: false,
537
+ frame: false,
538
+ transparent: true,
539
+ resizable: false,
540
+ alwaysOnTop: true,
541
+ skipTaskbar: true,
542
+ focusable: false,
543
+ hasShadow: false,
544
+ backgroundColor: '#00000000',
545
+ webPreferences: {
546
+ preload: path.join(__dirname, 'preload.js'),
547
+ contextIsolation: true,
548
+ sandbox: false,
549
+ },
550
+ })
551
+ win.setAlwaysOnTop(settings.onTop, TOP_LEVEL)
552
+ placePet(x, y)
553
+ win.webContents.setVisualZoomLevelLimits(1, 1)
554
+ if (!SHOW_PET) return
555
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
556
+ win.webContents.on('did-finish-load', () => {
557
+ sendLayout(true)
558
+ toRenderer({ type: 'link', connected: !!plugin })
559
+ applyVisibility()
560
+ })
561
+ }
562
+
563
+ // 卡片宽度:用户在右下角拖过就沿用拖出来的宽度(只记在内存里,Pointy重启回到默认)
564
+ let cardWidth = CARD_W
565
+
566
+ /** 卡片出现在光标附近(尽量在右下,放不下就往回挪) */
567
+ function cardBounds(height, width = cardWidth) {
568
+ const cursor = screen.getCursorScreenPoint()
569
+ const { workArea } = screen.getDisplayNearestPoint(cursor)
570
+ const x = Math.min(Math.max(cursor.x + 12, workArea.x), workArea.x + workArea.width - width)
571
+ const y = Math.min(Math.max(cursor.y + 16, workArea.y), workArea.y + workArea.height - height)
572
+ return { x, y, width, height }
573
+ }
574
+
575
+ // 测试钩子可以打开:卡片放到屏幕外面渲染,不显示、不抢焦点(用户在玩全屏游戏时也能测)
576
+ let offscreenCards = false
577
+
578
+ /** @param releasePick 指认留着的高亮框,卡片关掉或收起时撤掉 */
579
+ function openCard(init, releasePick) {
580
+ if (suppressed() && !TEST_HIDDEN && !offscreenCards) return releasePick?.()
581
+ // 眼前这张卡片让位:问过问题的缩成胶囊,没问过的关掉;收起的卡片不动
582
+ setAsideCard()
583
+ // 卡片跟着叫出它的那个程序:在它上面,切到别的程序就被盖住(配对卡片和找不到来源时照旧置顶)
584
+ const fg = init.mode === 'pair' ? 0n : foregroundAddress()
585
+ const mine = [win, ...cards].filter(w => w && !w.isDestroyed()).map(w => w.getNativeWindowHandle().readBigUInt64LE(0))
586
+ const owner = fg && !mine.includes(fg) ? fg : 0n
587
+ card = new BrowserWindow({
588
+ ...cardBounds(init.mode === 'pair' ? 230 : CARD_H_ASK),
589
+ show: false,
590
+ frame: false,
591
+ transparent: true,
592
+ resizable: false,
593
+ alwaysOnTop: !owner,
594
+ skipTaskbar: true,
595
+ hasShadow: false,
596
+ backgroundColor: '#00000000',
597
+ webPreferences: {
598
+ preload: path.join(__dirname, 'card', 'preload.js'),
599
+ contextIsolation: true,
600
+ sandbox: false,
601
+ },
602
+ })
603
+ const self = card
604
+ // 宽度只由这里和右下角拖拽决定;150% 缩放下用 getBounds 的宽度回写会一点点变宽
605
+ self.cardW = cardWidth
606
+ self.releasePick = releasePick
607
+ cards.add(self)
608
+ self.on('closed', () => {
609
+ self.releasePick?.()
610
+ cards.delete(self)
611
+ })
612
+ if (owner) {
613
+ try { setOwner(self.getNativeWindowHandle().readBigUInt64LE(0), owner); followOwner(self, owner) } catch (err) { logError('card-owner', err); self.setAlwaysOnTop(true, 'floating') }
614
+ } else {
615
+ card.setAlwaysOnTop(true, 'floating')
616
+ }
617
+ card.loadFile(path.join(__dirname, 'card', 'card.html'))
618
+ card.webContents.once('did-finish-load', () => {
619
+ card.webContents.send('card-init', init.mode === 'pair' ? init : { grab: init })
620
+ // 快捷键是用户主动按的,这里给卡片焦点好直接打字;全屏/勿扰时根本不会走到这里
621
+ // 测试:放到所有屏幕外面再显示,窗口照常重绘(截图才是新的),人看不到
622
+ if (TEST_HIDDEN || offscreenCards) { card.setPosition(-20000, -20000); card.showInactive(); return }
623
+ card.show()
624
+ card.focus()
625
+ })
626
+ card.on('closed', () => { if (card === self) card = undefined })
627
+ }
628
+
629
+ // ---------- 收起的任务:右下角托盘上方一叠胶囊 ----------
630
+ let pills = [] // { id: 'card<窗口 id>', sessionId, win, title, state: 'run' | 'done' | 'error' }
631
+ let pillWin
632
+ const PILL_W = 300
633
+
634
+ function updatePills() {
635
+ if (!pills.length) {
636
+ if (pillWin && !pillWin.isDestroyed()) pillWin.destroy()
637
+ pillWin = undefined
638
+ return
639
+ }
640
+ if (!pillWin || pillWin.isDestroyed()) {
641
+ pillWin = new BrowserWindow({
642
+ width: PILL_W, height: 60, show: false, frame: false, transparent: true, resizable: false,
643
+ skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
644
+ // 不节流:被别的窗口盖住时动画和量高度照常走
645
+ webPreferences: { preload: path.join(__dirname, 'pill', 'preload.js'), contextIsolation: true, backgroundThrottling: false },
646
+ })
647
+ pillWin.setAlwaysOnTop(true, 'floating')
648
+ pillWin.loadFile(path.join(__dirname, 'pill', 'pill.html'))
649
+ pillWin.webContents.once('did-finish-load', () => sendPills())
650
+ return
651
+ }
652
+ sendPills()
653
+ }
654
+ const sendPills = () => pillWin?.webContents.send('pills', pills.map(p => ({ id: p.id, title: p.title, state: p.state })))
655
+
656
+ /** 按内容高度贴在主屏右下角,托盘上方 */
657
+ function placePills(height) {
658
+ if (!pillWin || pillWin.isDestroyed()) return
659
+ const { workArea } = screen.getPrimaryDisplay()
660
+ const h = Math.max(1, Math.min(Math.ceil(height), workArea.height))
661
+ // 测试:放到屏幕外面,照常渲染、人看不到
662
+ const off = TEST_HIDDEN || offscreenCards
663
+ pillWin.setBounds({ x: off ? -20000 : workArea.x + workArea.width - PILL_W, y: off ? -20000 : workArea.y + workArea.height - h, width: PILL_W, height: h })
664
+ if (!pillWin.isVisible() && (off || !suppressed())) pillWin.showInactive()
665
+ }
666
+
667
+ /** 来源程序的窗口挪了,卡片跟着挪同样的距离(拖动卡片自己的位置不受影响,下次按新位置算) */
668
+ // 每帧看一次,拖窗口时才跟得顺;位置自己记小数,不从 getBounds 读回来(150% 缩放下取整会抖)
669
+ const FOLLOW_POLL_MS = 16
670
+ function followOwner(c, owner) {
671
+ let last = windowOrigin(owner)
672
+ if (!last) return
673
+ let pos // 卡片左上角(DIP,小数)
674
+ let placed // 上次我们放到的位置;用户自己拖了卡片就以那边为准
675
+ const timer = setInterval(() => {
676
+ if (c.isDestroyed()) return clearInterval(timer)
677
+ const now = windowOrigin(owner)
678
+ if (!now) return clearInterval(timer)
679
+ if (now.x === last.x && now.y === last.y) return
680
+ const a = screen.screenToDipPoint(last)
681
+ const b = screen.screenToDipPoint(now)
682
+ last = now
683
+ const dx = b.x - a.x
684
+ const dy = b.y - a.y
685
+ c.releasePick?.shift?.(dx, dy)
686
+ if (!c.isVisible()) return
687
+ const r = c.getBounds()
688
+ if (!pos || !placed || Math.abs(r.x - placed.x) > 2 || Math.abs(r.y - placed.y) > 2) pos = { x: r.x, y: r.y }
689
+ pos = { x: pos.x + dx, y: pos.y + dy }
690
+ placed = { x: Math.round(pos.x), y: Math.round(pos.y) }
691
+ c.setBounds({ ...placed, width: c.cardW ?? r.width, height: r.height })
692
+ }, FOLLOW_POLL_MS)
693
+ c.once('closed', () => clearInterval(timer))
694
+ }
695
+
696
+ ipcMain.on('card-asked', (e) => {
697
+ const c = BrowserWindow.fromWebContents(e.sender)
698
+ c?.releasePick?.()
699
+ if (c) c.releasePick = undefined
700
+ })
701
+
702
+ // 收起:卡片藏起来(窗口留着,DSH 接着往里写),右下角托盘上方多一条胶囊;点胶囊在原位置放回卡片
703
+ ipcMain.on('card-keep', (e, info) => {
704
+ const c = BrowserWindow.fromWebContents(e.sender)
705
+ if (!c) return
706
+ const sessionId = typeof info?.sessionId === 'string' ? info.sessionId : undefined
707
+ if (!sessionId) return c.close()
708
+ c.kept = true
709
+ c.keptBounds = c.getBounds()
710
+ // 往右下角托盘那边滑过去(卡片里同时缩小淡出),到了再藏
711
+ const off = TEST_HIDDEN || offscreenCards
712
+ if (off || !c.isVisible()) c.hide()
713
+ else glide(c, c.keptBounds, trayCorner(c.keptBounds), 220, () => { if (c.kept && !c.isDestroyed()) c.hide() })
714
+ c.releasePick?.()
715
+ c.releasePick = undefined
716
+ if (card === c) card = undefined
717
+ // 一张卡片一条胶囊(两张卡片可能问的是同一个会话)
718
+ const id = `card${c.id}`
719
+ pills = pills.filter(p => p.id !== id)
720
+ pills.push({ id, sessionId, win: c, fixedTitle: !!info.fixedTitle, title: String(info.title || '新任务'), state: info.state === 'done' || info.state === 'error' ? info.state : 'run' })
721
+ c.once('closed', () => { pills = pills.filter(p => p.win !== c); updatePills() })
722
+ updatePills()
723
+ })
724
+
725
+ /** 眼前这张卡片要让位(新指认、放回别的卡片):问过问题的缩成胶囊,没问过的直接关 */
726
+ function setAsideCard() {
727
+ if (!card || card.isDestroyed()) return
728
+ const old = card
729
+ card = undefined
730
+ old.webContents.send('card-set-aside')
731
+ // 还没加载完、收不到消息的卡片(刚弹出就又指认)直接关
732
+ setTimeout(() => { if (!old.isDestroyed() && !old.kept && old !== card) old.destroy() }, 1500)
733
+ }
734
+
735
+ // 插件报的会话状态:胶囊跟着变(DSH 起好标题就换掉问题原话;state 消息只换标题)
736
+ function pillFromPlugin(msg) {
737
+ const mine = pills.filter(x => x.sessionId === msg.sessionId)
738
+ if (!mine.length) return
739
+ // DSH 起正式标题前的临时标题就是我们发的消息开头("<抓取内容>…"),不要
740
+ const draft = /^(<抓取内容>|回答要求:)/.test(String(msg.title ?? '').trim())
741
+ let changed = false
742
+ for (const p of mine) {
743
+ if (msg.title && !draft && !p.fixedTitle && msg.title !== p.title) { p.title = msg.title; changed = true }
744
+ if (p.state !== 'run') continue
745
+ if (msg.type === 'ask-done') { p.state = msg.reason === 'completed' ? 'done' : 'error'; changed = true }
746
+ else if (msg.type === 'ask-error') { p.state = 'error'; changed = true }
747
+ }
748
+ if (changed) updatePills()
749
+ }
750
+
751
+ // 点胶囊:卡片回到收起前的位置(那块屏幕不在了才放到光标旁边),从胶囊那边放大出来
752
+ ipcMain.on('pill-open', (_e, id) => {
753
+ const p = pills.find(x => x.id === id)
754
+ logError('pill-open-diag', `click ${id} found=${!!p} win=${p?.win ? (p.win.isDestroyed() ? 'destroyed' : 'alive') : 'none'}`)
755
+ pills = pills.filter(x => x.id !== id)
756
+ updatePills()
757
+ const c = p?.win
758
+ if (!c || c.isDestroyed()) return
759
+ if (card !== c) setAsideCard()
760
+ c.kept = false
761
+ card = c
762
+ const k = c.keptBounds
763
+ const onScreen = k && screen.getAllDisplays().some(({ workArea: w }) => k.x < w.x + w.width && k.x + k.width > w.x && k.y < w.y + w.height && k.y + k.height > w.y)
764
+ const off = TEST_HIDDEN || offscreenCards
765
+ const to = off ? { ...c.getBounds(), x: -20000, y: -20000 } : onScreen ? { ...k, height: c.getBounds().height } : cardBounds(c.getBounds().height, c.cardW)
766
+ c.webContents.send('card-restore')
767
+ if (off) { c.setBounds(to); return c.showInactive() }
768
+ // 从右下角托盘那边滑回原位(卡片里同时放大)
769
+ const from = trayCorner(to)
770
+ c.setBounds(from)
771
+ c.show()
772
+ c.focus()
773
+ glide(c, from, to, 240)
774
+ // 排查:放回后卡片到底看不看得见(出过点胶囊后直接跳 DSH 的问题)
775
+ setTimeout(() => {
776
+ if (c.isDestroyed()) return logError('pill-open-diag', 'card destroyed')
777
+ c.webContents.executeJavaScript(`JSON.stringify({ op: getComputedStyle(document.getElementById('card')).opacity, cls: document.getElementById('card').className, anim: document.getElementById('card').style.animation })`)
778
+ .then(r => logError('pill-open-diag', `visible=${c.isVisible()} bounds=${JSON.stringify(c.getBounds())} ${r}`), err => logError('pill-open-diag', err))
779
+ }, 600)
780
+ })
781
+
782
+ /** 卡片窗口右下角贴到主屏右下角(胶囊那里)时的位置,大小不变 */
783
+ function trayCorner(b) {
784
+ const { workArea: w } = screen.getPrimaryDisplay()
785
+ return { ...b, x: w.x + w.width - b.width, y: w.y + w.height - b.height }
786
+ }
787
+
788
+ /** 窗口从 from 滑到 to(只动位置),先快后慢 */
789
+ function glide(c, from, to, ms, done) {
790
+ clearInterval(c.glideTimer)
791
+ const t0 = Date.now()
792
+ c.glideTimer = setInterval(() => {
793
+ if (c.isDestroyed()) return clearInterval(c.glideTimer)
794
+ const t = Math.min(1, (Date.now() - t0) / ms)
795
+ const e = 1 - (1 - t) ** 3
796
+ c.setBounds({ x: Math.round(from.x + (to.x - from.x) * e), y: Math.round(from.y + (to.y - from.y) * e), width: to.width, height: to.height })
797
+ if (t < 1) return
798
+ clearInterval(c.glideTimer)
799
+ done?.()
800
+ }, 16)
801
+ }
802
+ // ×:不看了,卡片也一起关掉(DSH 那边不受影响)
803
+ ipcMain.on('pill-dismiss', (_e, id) => {
804
+ const p = pills.find(x => x.id === id)
805
+ pills = pills.filter(x => x.id !== id)
806
+ updatePills()
807
+ if (p?.win && !p.win.isDestroyed()) p.win.destroy()
808
+ })
809
+ ipcMain.on('pill-resize', (_e, height) => placePills(Number(height) || 0))
810
+
811
+ let lastGrab // 测试钩子用:上一次快捷键取到的东西(含 method)
812
+
813
+ /** 浏览器里:让扩展进入指认模式,等用户点一下(或 Esc) */
814
+ function pickInBrowser(exe, cursor, urlHint) {
815
+ const want = exe === 'msedge.exe' ? 'edge' : 'chrome'
816
+ const ext = [...extensions.values()].find(e => e.browser === want) ?? [...extensions.values()][0]
817
+ if (!ext || ext.ws.readyState !== ext.ws.OPEN) return Promise.resolve({ type: 'pick-unavailable' })
818
+ return new Promise((resolve) => {
819
+ const reqId = crypto.randomUUID()
820
+ const timer = setTimeout(() => {
821
+ picking = undefined
822
+ // 让扩展把页面上的高亮层撤掉
823
+ if (ext.ws.readyState === ext.ws.OPEN) ext.ws.send(JSON.stringify({ type: 'pick-abort', reqId }))
824
+ resolve({ type: 'pick-cancel' })
825
+ }, PICK_TIMEOUT_MS)
826
+ // 扩展几秒内没回"开始了"(service worker 卡住、连接半死),别干等一分钟,改走系统层面的指认
827
+ const startTimer = setTimeout(() => {
828
+ if (picking?.reqId !== reqId || picking.started) return
829
+ logError('pick-no-start', `extension did not start pick ${reqId}`)
830
+ clearTimeout(timer)
831
+ picking = undefined
832
+ resolve({ type: 'pick-unavailable' })
833
+ }, PICK_START_TIMEOUT_MS)
834
+ picking = {
835
+ reqId,
836
+ at: Date.now(),
837
+ // 选中后网页上的高亮框留着,卡片关掉时用 release 让扩展撤掉
838
+ resolve: (msg) => {
839
+ clearTimeout(timer)
840
+ clearTimeout(startTimer)
841
+ picking = undefined
842
+ resolve({ ...msg, release: () => { if (ext.ws.readyState === ext.ws.OPEN) ext.ws.send(JSON.stringify({ type: 'pick-release', reqId })) } })
843
+ },
844
+ abort: () => { ext.ws.send(JSON.stringify({ type: 'pick-abort', reqId })); picking?.resolve({ type: 'pick-cancel' }) },
845
+ }
846
+ ext.ws.send(JSON.stringify({ type: 'pick-start', reqId, cursor, urlHint }))
847
+ })
848
+ }
849
+
850
+ /** 浏览器里指认一块内容并打开卡片;返回扩展的结果类型 */
851
+ async function pickAndOpen(fg, cursor, urlHint) {
852
+ const r = await pickInBrowser(fg.exe, cursor, urlHint)
853
+ if (r.type === 'picked') {
854
+ // 指到图片、视频、画布时扩展会附一张那块的截图(只在内存里)
855
+ openCard({ source: { app: fg.exe, title: fg.title }, method: 'pick', url: r.url, pageTitle: r.pageTitle, element: r.element, text: r.element?.text, image: r.image }, r.release)
856
+ }
857
+ return r.type
858
+ }
859
+
860
+ // 晃动被识别出来却没响应时记一笔原因(30 秒最多一条),"晃了没反应"时查 error.log 就知道是哪条拦的
861
+ let lastIgnoreLog = 0
862
+ function shakeIgnored() {
863
+ const reason = !settings.shake ? '' : fullscreen ? 'fullscreen' : settings.dnd ? 'dnd' : pickBusy() ? 'picking' : card?.isFocused() ? 'card-focused' : TEST_HIDDEN ? 'test-hidden' : undefined
864
+ if (reason && Date.now() - lastIgnoreLog > 30_000) { lastIgnoreLog = Date.now(); logError('shake-ignored', reason) }
865
+ return reason !== undefined
866
+ }
867
+
868
+ /** 晃一下鼠标:浏览器里走扩展的指认,其他地方先退回取选区(系统层面的指认在后面一步) */
869
+ /** 屏幕正下方一句提示,样子同指认时的操作提示,几秒后自己消失(桌宠不显示了,气泡看不到) */
870
+ let hintWin
871
+ let hintTimer
872
+ function showHint(text) {
873
+ if (TEST_HIDDEN) return
874
+ const { workArea } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
875
+ const width = 520
876
+ const height = 64
877
+ if (!hintWin || hintWin.isDestroyed()) {
878
+ hintWin = new BrowserWindow({
879
+ width, height, show: false, frame: false, transparent: true, resizable: false, focusable: false,
880
+ skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
881
+ })
882
+ hintWin.setIgnoreMouseEvents(true)
883
+ hintWin.setAlwaysOnTop(true, 'screen-saver')
884
+ }
885
+ hintWin.setBounds({ x: Math.round(workArea.x + (workArea.width - width) / 2), y: workArea.y + workArea.height - height - 40, width, height })
886
+ const esc = (s) => s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))
887
+ const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;background:transparent;display:flex;align-items:center;justify-content:center;height:100vh;overflow:hidden">
888
+ <div style="padding:10px 22px;border-radius:999px;background:rgba(32,33,36,.92);color:#fff;font:16px 'Segoe UI','Microsoft YaHei UI',sans-serif;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.18)">${esc(text)}</div></body>`
889
+ hintWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`).then(() => hintWin?.showInactive()).catch(() => {})
890
+ clearTimeout(hintTimer)
891
+ hintTimer = setTimeout(() => { if (hintWin && !hintWin.isDestroyed()) hintWin.hide() }, 2600)
892
+ }
893
+
894
+ async function onShake(x, y) {
895
+ if (suppressed() || pickBusy()) return
896
+ const fg = foreground()
897
+ const blocked = blockedReason(fg)
898
+ if (blocked) return toRenderer({ type: 'notice', text: `这里我不碰:${blocked}`, kind: 'error' })
899
+ // 管理员程序:读不到控件、点击也拦不住,进了指认会卡住,直接提示
900
+ if (elevatedUnderCursor()) return showHint('这个程序以管理员身份运行,Pointy 读不到')
901
+ toRenderer({ type: 'notice', text: '指给我看~' })
902
+ if (BROWSERS.has(fg.exe) && extensions.size) {
903
+ const r = await pickAndOpen(fg, { x, y })
904
+ if (r !== 'pick-unavailable') return
905
+ }
906
+ // 晃动可能是误触,这条路绝不模拟按键。已经选中文字就用选区,没有就进系统层面的指认
907
+ const grab = await grabSelection(undefined, { allowCopy: false })
908
+ if (grab.blocked) return toRenderer({ type: 'notice', text: `这里我不碰:${grab.blocked}`, kind: 'error' })
909
+ if (grab.text?.trim()) return openCard(grab)
910
+ return pickNativeAndOpen(fg)
911
+ }
912
+
913
+ /** 系统层面的指认:指一块、确认后开卡片(读不到字的附截图) */
914
+ async function pickNativeAndOpen(fg) {
915
+ picking = { reqId: 'native', at: Date.now(), resolve: () => {} }
916
+ let r
917
+ try { r = await pickNative() } finally { picking = undefined }
918
+ if (!r) return
919
+ if (r.blocked) { r.release?.(); return toRenderer({ type: 'notice', text: `这里我不碰:${r.blocked}`, kind: 'error' }) }
920
+ // 读不到内容的(QQ 这类关了无障碍的 Electron 程序只露出一个空壳窗口)就附截图
921
+ if (settings.debugPick) logError('pick-diag', JSON.stringify({ exe: fg.exe, ...r.diag }))
922
+ const shell = !!r.opaque
923
+ const element = shell ? { ...r.element, text: '', fields: { 控件: '程序界面' } } : r.element
924
+ // 读不到字、或者框太大只截了鼠标附近一块,都带上截图
925
+ const thin = shell || r.focused || (r.element.text ?? '').trim().length < 30
926
+ openCard({ source: { app: fg.exe, title: fg.title }, method: 'uia-pick', element: { ...element, site: fg.exe }, text: element.text, image: thin ? r.image : undefined, wideImage: r.wide, shotKind: r.focused ? 'focus' : 'element' }, r.release)
927
+ }
928
+
929
+ // ---------- 提示窗:首次引导、扩展配对、装扩展的步骤 ----------
930
+ let noticeWin
931
+ let noticeDone // 当前提示窗的回调:点了按钮 / 关掉 / 超时 / 被新的顶掉都走这里
932
+ const NOTICE_W = 380
933
+
934
+ /** 右下角弹一个小窗,返回用户点的按钮 id;关掉或超时是 null */
935
+ function showNotice({ title, body, buttons, timeoutMs }) {
936
+ noticeDone?.(null)
937
+ return new Promise((resolve) => {
938
+ const { workArea } = screen.getPrimaryDisplay()
939
+ const w = new BrowserWindow({
940
+ width: NOTICE_W, height: 200, x: workArea.x + workArea.width - NOTICE_W - 12, y: workArea.y + workArea.height - 212,
941
+ show: false, frame: false, transparent: true, resizable: false, skipTaskbar: true, hasShadow: false,
942
+ alwaysOnTop: true, backgroundColor: '#00000000',
943
+ webPreferences: { preload: path.join(__dirname, 'notice', 'preload.js'), contextIsolation: true },
944
+ })
945
+ w.setAlwaysOnTop(true, 'floating')
946
+ noticeWin = w
947
+ let timer
948
+ const done = (choice) => {
949
+ if (noticeDone !== done) return
950
+ noticeDone = undefined
951
+ clearTimeout(timer)
952
+ if (!w.isDestroyed()) w.close()
953
+ if (noticeWin === w) noticeWin = undefined
954
+ resolve(choice)
955
+ }
956
+ noticeDone = done
957
+ if (timeoutMs) timer = setTimeout(() => done(null), timeoutMs)
958
+ w.on('closed', () => done(null))
959
+ w.loadFile(path.join(__dirname, 'notice', 'notice.html'))
960
+ w.webContents.once('did-finish-load', () => w.webContents.send('notice-init', { title, body, buttons }))
961
+ })
962
+ }
963
+ ipcMain.on('notice-choose', (e, id) => { if (noticeWin && e.sender === noticeWin.webContents) noticeDone?.(typeof id === 'string' ? id : null) })
964
+ ipcMain.on('notice-resize', (e, height) => {
965
+ const w = noticeWin
966
+ if (!w || e.sender !== w.webContents || !Number.isFinite(height)) return
967
+ const h = Math.min(Math.max(Math.round(height), 80), 600)
968
+ const { workArea } = screen.getPrimaryDisplay()
969
+ w.setBounds({ x: workArea.x + workArea.width - NOTICE_W - 12, y: workArea.y + workArea.height - h - 12, width: NOTICE_W, height: h })
970
+ if (TEST_HIDDEN) { w.setPosition(-20000, -20000); w.showInactive() } else if (!w.isVisible()) w.showInactive()
971
+ })
972
+
973
+ /** 浏览器扩展还不在商店里:打开 ~/.pointy/extension,告诉用户怎么"加载已解压的扩展程序" */
974
+ async function showExtensionGuide() {
975
+ syncUserExtension()
976
+ if (!TEST_HIDDEN) shell.openPath(USER_EXTENSION_DIR)
977
+ const choice = await showNotice({
978
+ title: '安装浏览器扩展(推荐)',
979
+ body: [
980
+ '装了扩展,在网页里能指认到具体的段落、图片,还能右键「问 Pointy」。没装也能用,只是网页按整窗截图处理。',
981
+ '1. 在 Chrome 或 Edge 地址栏打开 **chrome://extensions**(Edge 是 edge://extensions),打开右上角的「开发者模式」。',
982
+ '2. 点「加载已解压的扩展程序」,选刚才打开的文件夹:**' + USER_EXTENSION_DIR + '**',
983
+ '3. 装好后 Pointy 会弹窗问要不要让它连接,点「允许」就行。',
984
+ ],
985
+ buttons: [{ id: 'copy', label: '复制 chrome://extensions' }, { id: 'ok', label: '知道了', primary: true }],
986
+ })
987
+ if (choice === 'copy') clipboard.writeText('chrome://extensions')
988
+ }
989
+
990
+ /** 第一次运行:告诉用户怎么用,顺便推荐装扩展 */
991
+ async function showWelcome() {
992
+ saveSettings({ welcomed: true })
993
+ const choice = await showNotice({
994
+ title: 'Pointy 已就位',
995
+ body: [
996
+ '**晃一下鼠标**进入指认,指到网页、文档、聊天窗口里的任意内容,点一下就能问 DSH。',
997
+ '托盘里的鲸鱼图标可以调晃动灵敏度、开勿扰。',
998
+ '在浏览器里用的话,推荐装上 Pointy 的浏览器扩展,能指认到网页里具体的段落。',
999
+ ],
1000
+ buttons: [{ id: 'later', label: '以后再说' }, { id: 'ext', label: '安装浏览器扩展', primary: true }],
1001
+ })
1002
+ if (choice === 'ext') showExtensionGuide()
1003
+ }
1004
+
1005
+ const trayIcon = (frame) => SHOW_PET
1006
+ ? nativeImage.createFromPath(path.join(SPRITES, `${frame}.png`)).resize({ width: 32, height: 32 })
1007
+ : nativeImage.createFromPath(TRAY_ICON)
1008
+
1009
+ function updateTray() {
1010
+ if (!tray) return
1011
+ const hiddenWaiting = pendingApprovals > 0 && suppressed()
1012
+ tray.setImage(trayIcon(hiddenWaiting ? 'waiting' : 'idle'))
1013
+ // 悬停就能看出现在是什么模式
1014
+ const modes = [
1015
+ settings.dnd ? '勿扰中' : fullscreen ? '全屏中已隐藏' : (settings.onTop ? '置顶' : '不置顶'),
1016
+ plugin ? `已连DSH${pluginVersion ? `(插件${pluginVersion})` : ''}` : 'DSH未连接',
1017
+ ].filter(Boolean).join('·')
1018
+ const waiting = [
1019
+ pendingApprovals > 0 ? `${pendingApprovals} 个操作等你批准` : '',
1020
+ ].filter(Boolean).join(',')
1021
+ tray.setToolTip(waiting ? `Pointy:${waiting}\n${modes}` : `Pointy:${modes}`)
1022
+ if (!SHOW_PET) {
1023
+ const status = [
1024
+ pendingApprovals > 0 ? `${pendingApprovals} 个操作等你批准` : '',
1025
+ settings.dnd ? '勿扰中' : '',
1026
+ plugin ? '' : 'DSH 未连接',
1027
+ ].filter(Boolean).join('\n')
1028
+ tray.setToolTip(status ? `Pointy\n${status}` : 'Pointy')
1029
+ }
1030
+ // 设置只留常用的几项;置顶、开机自启等不在菜单里(settings.json 和系统启动项照样生效)
1031
+ tray.setContextMenu(Menu.buildFromTemplate(SHOW_PET ? petMenu() : [
1032
+ {
1033
+ label: '晃一下鼠标指认',
1034
+ submenu: [
1035
+ { label: '关', type: 'radio', checked: !settings.shake, click: () => { saveSettings({ shake: false }); updateTray() } },
1036
+ ...[['low', '低:要晃得明显'], ['medium', '中'], ['high', '高:轻晃就行']].map(([value, label]) => ({
1037
+ label, type: 'radio', checked: settings.shake && settings.shakeLevel === value, click: () => { saveSettings({ shake: true, shakeLevel: value }); updateTray() },
1038
+ })),
1039
+ ],
1040
+ },
1041
+ extensions.size
1042
+ ? { label: `浏览器扩展已连接(${extensions.size})`, enabled: false }
1043
+ : { label: '安装浏览器扩展(推荐)…', click: () => showExtensionGuide() },
1044
+ { type: 'separator' },
1045
+ { label: '勿扰模式', type: 'checkbox', checked: settings.dnd, click: (item) => { saveSettings({ dnd: item.checked }); applyVisibility() } },
1046
+ { type: 'separator' },
1047
+ { label: '退出 Pointy', click: () => app.quit() },
1048
+ ]))
1049
+ }
1050
+
1051
+ /** 旧的Pointy菜单(SHOW_PET 打开时用) */
1052
+ function petMenu() {
1053
+ return [
1054
+ { label: '晃一下鼠标触发', type: 'checkbox', checked: settings.shake, click: (item) => { saveSettings({ shake: item.checked }); updateTray() } },
1055
+ {
1056
+ label: '晃动灵敏度',
1057
+ submenu: [['low', '低(要晃得明显)'], ['medium', '中'], ['high', '高(轻晃就行)']].map(([value, label]) => ({
1058
+ label, type: 'radio', checked: settings.shakeLevel === value, click: () => { saveSettings({ shakeLevel: value }); updateTray() },
1059
+ })),
1060
+ },
1061
+ { label: `浏览器扩展:${extensions.size ? `已连接 ${extensions.size} 个` : '未连接'}`, enabled: false },
1062
+ { label: '安装浏览器扩展…', click: () => showExtensionGuide() },
1063
+ { type: 'separator' },
1064
+ { label: '勿扰模式', type: 'checkbox', checked: settings.dnd, click: (item) => { saveSettings({ dnd: item.checked }); applyVisibility() } },
1065
+ { label: '置顶', type: 'checkbox', checked: settings.onTop, click: (item) => { saveSettings({ onTop: item.checked }); applyOnTop(); updateTray() } },
1066
+ {
1067
+ label: '人物大小',
1068
+ submenu: [
1069
+ ...SIZE_PRESETS.map(([label, value]) => ({
1070
+ label: `${label}(${Math.round(value * 100)}%)`, type: 'radio', checked: Math.abs(petScale() - value) < 0.01, click: () => setScale(value),
1071
+ })),
1072
+ { label: `当前 ${Math.round(petScale() * 100)}% · 在人物上 Ctrl+滚轮可微调`, enabled: false },
1073
+ ],
1074
+ },
1075
+ { label: '回到右下角', click: () => { homePos = undefined; placePet(...homePosition()) } },
1076
+ { type: 'separator' },
1077
+ { type: 'separator' },
1078
+ { label: '退出', click: () => app.quit() },
1079
+ ]
1080
+ }
1081
+
1082
+ function createTray() {
1083
+ tray = new Tray(trayIcon('idle'))
1084
+ updateTray()
1085
+ if (!SHOW_PET) {
1086
+ tray.on('balloon-click', () => { const f = notifyClick; notifyClick = undefined; f?.() })
1087
+ }
1088
+ }
1089
+
1090
+ function ownHandles() {
1091
+ return [win, card].filter(Boolean).map(w => w.getNativeWindowHandle().readBigUInt64LE(0))
1092
+ }
1093
+
1094
+ function pollFullscreen() {
1095
+ let now = false
1096
+ try { now = isFullscreenActive(ownHandles()) } catch {}
1097
+ if (now !== fullscreen) {
1098
+ fullscreen = now
1099
+ applyVisibility()
1100
+ }
1101
+ }
1102
+
1103
+ ipcMain.on('move-by', (_e, dx, dy) => {
1104
+ if (!win || !Number.isFinite(dx) || !Number.isFinite(dy)) return
1105
+ placePet(petPos[0] + dx, petPos[1] + dy)
1106
+ })
1107
+
1108
+ // 等批准时跑到屏幕中下方,处理完回原位;被压住时只记下来,恢复显示时再跑
1109
+ ipcMain.on('front', (_e, on) => {
1110
+ if (!win) return
1111
+ wantFront = on
1112
+ if (on) {
1113
+ if (!suppressed()) moveToFront()
1114
+ } else if (homePos) {
1115
+ placePet(...homePos)
1116
+ homePos = undefined
1117
+ }
1118
+ })
1119
+
1120
+ ipcMain.on('approvals', (_e, count) => {
1121
+ pendingApprovals = Number(count) || 0
1122
+ updateTray()
1123
+ })
1124
+
1125
+ const sendToPlugin = (msg) => {
1126
+ if (!plugin) return false
1127
+ plugin.send(JSON.stringify(msg))
1128
+ return true
1129
+ }
1130
+ ipcMain.handle('to-plugin', (_e, msg) => sendToPlugin(msg))
1131
+ ipcMain.handle('card-to-plugin', (_e, msg) => sendToPlugin(msg))
1132
+ ipcMain.on('card-close', (e) => BrowserWindow.fromWebContents(e.sender)?.close())
1133
+ // 关卡片 → 把 DSH 提到前台 → 让插件在 DSH 里切到这张卡片对应的会话
1134
+ ipcMain.on('card-open-dsh', (e, sessionId) => {
1135
+ logError('card-open-dsh-diag', `from card, sessionId=${sessionId}`)
1136
+ BrowserWindow.fromWebContents(e.sender)?.close()
1137
+ openDsh(sessionId)
1138
+ })
1139
+
1140
+ /**
1141
+ * 把 DSH 叫到前台并切到这个会话。桌面版走 dsh://open;web 版没有窗口可拉,
1142
+ * 有浏览器扩展就让它把开着 DSH 的标签页切到前面,没扩展只能在原标签页里切会话。
1143
+ */
1144
+ function openDsh(sessionId) {
1145
+ allowAnyForeground()
1146
+ if (dshUi.kind === 'web') {
1147
+ const ext = [...extensions.values()][0]
1148
+ if (ext && dshUi.origin) ext.ws.send(JSON.stringify({ type: 'focus-tab', origin: dshUi.origin }))
1149
+ else showHint('已在浏览器里的 DSH 切到这个会话')
1150
+ } else {
1151
+ shell.openExternal('dsh://open')
1152
+ }
1153
+ if (typeof sessionId === 'string' && sessionId) sendToPlugin({ type: 'focus', sessionId })
1154
+ }
1155
+ // 只认 http(s) 和本机绝对路径;本机文件只在资源管理器里定位,不直接运行
1156
+ ipcMain.on('card-open-link', (_e, href) => {
1157
+ const s = String(href ?? '')
1158
+ if (/^https?:\/\//i.test(s)) shell.openExternal(s)
1159
+ else if (/^[a-z]:[\\/]/i.test(s) && fs.existsSync(s)) shell.showItemInFolder(s)
1160
+ })
1161
+ ipcMain.on('card-copy', (_e, text) => clipboard.writeText(String(text ?? '')))
1162
+ // 内容变高时跟着变高;用户拖过大小之后就不再自动改
1163
+ ipcMain.on('card-resize', (e, height) => {
1164
+ const c = BrowserWindow.fromWebContents(e.sender)
1165
+ if (!c || c.manualSize) return
1166
+ const b = c.getBounds()
1167
+ const { workArea } = screen.getDisplayMatching(b)
1168
+ const h = Math.min(Number(height) || CARD_H_ANSWER, workArea.height)
1169
+ // 下拉开着、窗口被临时撑高时只记下来,关菜单时还原到这个高度
1170
+ if (c.menuBaseH) { c.menuBaseH = h; return }
1171
+ c.setBounds({ x: b.x, y: Math.min(b.y, workArea.y + workArea.height - h), width: c.cardW ?? cardWidth, height: h })
1172
+ })
1173
+ // 卡片的下拉比窗口长:临时把窗口往下撑高(手动调过大小也撑),0 = 还原
1174
+ ipcMain.on('card-menu-room', (e, height) => {
1175
+ const c = BrowserWindow.fromWebContents(e.sender)
1176
+ if (!c) return
1177
+ const b = c.getBounds()
1178
+ if (!height) {
1179
+ if (c.menuBaseH) c.setBounds({ ...b, height: c.menuBaseH })
1180
+ c.menuBaseH = undefined
1181
+ return
1182
+ }
1183
+ if (height <= b.height) return
1184
+ const { workArea } = screen.getDisplayMatching(b)
1185
+ c.menuBaseH ??= b.height
1186
+ c.setBounds({ ...b, height: Math.min(Math.ceil(height), workArea.y + workArea.height - b.y) })
1187
+ })
1188
+ // 右下角拖拽:按下时记住窗口和光标,拖动时用主进程拿到的光标位置算(不用渲染进程的坐标,缩放下不准)
1189
+ let gripDrag
1190
+ ipcMain.on('card-grip-start', (e) => {
1191
+ const c = BrowserWindow.fromWebContents(e.sender)
1192
+ if (c) gripDrag = { c, b: c.getBounds(), p: screen.getCursorScreenPoint() }
1193
+ })
1194
+ ipcMain.on('card-grip-move', (e) => {
1195
+ const { c, b, p } = gripDrag ?? {}
1196
+ if (!c || c.isDestroyed() || c !== BrowserWindow.fromWebContents(e.sender)) return
1197
+ const now = screen.getCursorScreenPoint()
1198
+ const { workArea } = screen.getDisplayMatching(b)
1199
+ const width = Math.round(Math.min(Math.max(CARD_MIN_W, b.width + now.x - p.x), workArea.x + workArea.width - b.x))
1200
+ const height = Math.round(Math.min(Math.max(CARD_MIN_H, b.height + now.y - p.y), workArea.y + workArea.height - b.y))
1201
+ c.manualSize = true
1202
+ c.cardW = width
1203
+ cardWidth = width
1204
+ c.setBounds({ x: b.x, y: b.y, width, height })
1205
+ })
1206
+ ipcMain.on('card-grip-end', () => { gripDrag = undefined })
1207
+
1208
+ ipcMain.on('menu', () => tray.popUpContextMenu())
1209
+
1210
+ let captureSeq = 0
1211
+ async function capture(target, name) {
1212
+ if (!CAPTURE_DIR || !target) return
1213
+ await new Promise(r => setTimeout(r, 400))
1214
+ const image = await target.webContents.capturePage()
1215
+ fs.mkdirSync(CAPTURE_DIR, { recursive: true })
1216
+ fs.writeFileSync(path.join(CAPTURE_DIR, `${String(++captureSeq).padStart(2, '0')}-${name}.png`), image.toPNG())
1217
+ }
1218
+ ipcMain.on('rendered', (_e, state) => capture(win, state))
1219
+ ipcMain.on('card-rendered', (_e, name) => capture(card, `card-${name}`))
1220
+
1221
+ // 换了端口的实例(测试用)各用各的 userData,单实例锁才不会互相挡住
1222
+ if (process.env.POINTY_PORT) app.setPath('userData', `${app.getPath('userData')}-${PORT}`)
1223
+ // 开机自启和 DSH 拉起可能同时发生,只留一个
1224
+ if (!app.requestSingleInstanceLock()) app.exit(0)
1225
+
1226
+ app.whenReady().then(() => {
1227
+ // 上次指认中途崩溃的话系统光标还是鲸鱼娘,启动时先恢复成用户自己的
1228
+ if (!TEST_HIDDEN) restoreCursor(true)
1229
+ if (!TEST_HIDDEN) { dropOldAutostart(); syncUserExtension() }
1230
+ startServer(loadToken())
1231
+ createWindow()
1232
+ if (!TEST_HIDDEN) {
1233
+ createTray()
1234
+ startShakeDetector({
1235
+ onShake: (x, y) => { if (settings.shake) onShake(x, y).catch(() => {}) },
1236
+ shouldIgnore: () => shakeIgnored(),
1237
+ level: () => settings.shakeLevel,
1238
+ })
1239
+ }
1240
+ if (!TEST_HIDDEN && !settings.welcomed) setTimeout(() => showWelcome().catch((err) => logError('welcome', err)), 2500)
1241
+ pollFullscreen()
1242
+ setInterval(pollFullscreen, FULLSCREEN_POLL_MS)
1243
+ setInterval(() => { try { keepOnTop() } catch {} }, TOPMOST_POLL_MS)
1244
+ setInterval(updateClickThrough, CLICK_POLL_MS)
1245
+ if (TEST_HIDDEN || process.env.POINTY_TEST_HOOKS === '1') global.pointyTest = { openCard, openDsh, dshUi: () => dshUi, showNotice, showWelcome, showExtensionGuide, noticeChoose: (id) => noticeDone?.(id), noticeOpen: () => !!noticeWin, extensionGrabFor, onShake, getCard: () => card, petBounds: () => win.getBounds(), allCards: () => [...cards], lastGrab: () => lastGrab, selection: require('./selection'), pickAndOpen, pickNative, extensionCount: () => extensions.size, lastPickTab: () => lastPickTab, abortPick: () => picking?.abort?.(), setOffscreenCards: (on) => { offscreenCards = on }, pills: () => pills.map(({ win, ...p }) => p), pillBounds: () => pillWin?.getBounds() }
1246
+ })
1247
+ app.on('window-all-closed', () => {})
1248
+ app.on('will-quit', () => {
1249
+ restoreCursor()
1250
+ globalShortcut.unregisterAll()
1251
+ stopSelectionHelper()
1252
+ })