@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.
- package/ASSETS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +33 -0
- package/package.json +38 -4
- package/pet/app/card/card.css +365 -0
- package/pet/app/card/card.html +68 -0
- package/pet/app/card/card.js +896 -0
- package/pet/app/card/markdown.js +64 -0
- package/pet/app/card/mascot/blink.png +0 -0
- package/pet/app/card/mascot/done.png +0 -0
- package/pet/app/card/mascot/error.png +0 -0
- package/pet/app/card/mascot/idle.png +0 -0
- package/pet/app/card/mascot/peek.png +0 -0
- package/pet/app/card/mascot/think.png +0 -0
- package/pet/app/card/preload.js +21 -0
- package/pet/app/fullscreen.js +131 -0
- package/pet/app/main.js +1252 -0
- package/pet/app/native/uia-selection.ps1 +158 -0
- package/pet/app/notice/notice.html +37 -0
- package/pet/app/notice/notice.js +31 -0
- package/pet/app/notice/preload.js +7 -0
- package/pet/app/overlay/overlay.html +30 -0
- package/pet/app/overlay/overlay.js +42 -0
- package/pet/app/overlay/preload.js +7 -0
- package/pet/app/pill/pill.css +73 -0
- package/pet/app/pill/pill.html +11 -0
- package/pet/app/pill/pill.js +35 -0
- package/pet/app/pill/preload.js +8 -0
- package/pet/app/preload.js +16 -0
- package/pet/app/renderer/index.html +24 -0
- package/pet/app/renderer/lines.js +25 -0
- package/pet/app/renderer/pet.js +292 -0
- package/pet/app/renderer/renderer2d.js +71 -0
- package/pet/app/renderer/style.css +157 -0
- package/pet/app/selection.js +246 -0
- package/pet/app/shake.js +76 -0
- package/pet/app/uipick.js +314 -0
- package/pet/assets/cursor/cursor-32.cur +0 -0
- package/pet/assets/cursor/cursor-32.png +0 -0
- package/pet/assets/cursor/cursor-48.cur +0 -0
- package/pet/assets/cursor/cursor-48.png +0 -0
- package/pet/assets/cursor/cursor-64.cur +0 -0
- package/pet/assets/cursor/cursor-64.png +0 -0
- package/pet/assets/cursor/cursor-clean.png +0 -0
- package/pet/assets/cursor/make_cursor.py +85 -0
- package/pet/assets/cursor/source.png +0 -0
- package/pet/assets/pointy.ico +0 -0
- package/pet/assets/tray.png +0 -0
- package/pet/assets/tray@2x.png +0 -0
- package/pet/boot.js +6 -0
- package/pet/extension/background.js +252 -0
- package/pet/extension/cursor-32.png +0 -0
- package/pet/extension/cursor-48.png +0 -0
- package/pet/extension/cursor-64.png +0 -0
- package/pet/extension/icon.png +0 -0
- package/pet/extension/manifest.json +12 -0
- package/pet/extension/pick.js +391 -0
- package/pet/extension/popup.html +29 -0
- package/pet/extension/popup.js +30 -0
- package/pet/package.json +11 -0
- package/pet/vendor/koffi/LICENSE.txt +22 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
- package/pet/vendor/koffi/index.js +634 -0
- package/pet/vendor/koffi/indirect.js +533 -0
- package/pet/vendor/koffi/package.json +35 -0
- package/pet/vendor/ws/LICENSE +20 -0
- package/pet/vendor/ws/index.js +22 -0
- package/pet/vendor/ws/lib/buffer-util.js +142 -0
- package/pet/vendor/ws/lib/constants.js +19 -0
- package/pet/vendor/ws/lib/event-target.js +292 -0
- package/pet/vendor/ws/lib/extension.js +203 -0
- package/pet/vendor/ws/lib/limiter.js +55 -0
- package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
- package/pet/vendor/ws/lib/receiver.js +743 -0
- package/pet/vendor/ws/lib/sender.js +610 -0
- package/pet/vendor/ws/lib/stream.js +161 -0
- package/pet/vendor/ws/lib/subprotocol.js +62 -0
- package/pet/vendor/ws/lib/validation.js +152 -0
- package/pet/vendor/ws/lib/websocket-server.js +562 -0
- package/pet/vendor/ws/lib/websocket.js +1426 -0
- package/pet/vendor/ws/package.json +74 -0
- package/pet/vendor/ws/wrapper.mjs +21 -0
- package/src/node.js +732 -0
- package/src/runtime.js +178 -0
package/pet/app/main.js
ADDED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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
|
+
})
|