@followbin/dsh-selection 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +140 -2
- package/README.zh-CN.md +141 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/client.js +1055 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +1 -0
- package/index.js +41 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -4
- package/src/host/files.mjs +29 -0
- package/src/host/http.mjs +153 -0
- package/src/host/llm.mjs +194 -0
- package/src/host/log.mjs +40 -0
- package/src/host/service.mjs +324 -0
- package/src/host/settings.mjs +279 -0
- package/src/host/skills.mjs +88 -0
- package/src/host/supervisor.mjs +126 -0
- package/src/host/usage.mjs +128 -0
- package/src/shared/icons.mjs +43 -0
- package/src/shared/open-with-system.mjs +15 -0
- package/src/shared/paths.mjs +7 -0
- package/src/shared/protocol.mjs +4 -0
- package/src/worker/controller.mjs +261 -0
- package/src/worker/main.mjs +147 -0
- package/src/worker/native-window.mjs +319 -0
- package/src/worker/open-url.mjs +20 -0
- package/src/worker/overlay.mjs +263 -0
- package/src/worker/page.mjs +14 -0
- package/src/worker/placement.mjs +58 -0
- package/src/worker/speech.mjs +136 -0
- package/ui/markdown.js +122 -0
- package/ui/toolbar.css +152 -0
- package/ui/toolbar.html +52 -0
- package/ui/toolbar.js +492 -0
- package/vendor/selection-hook/LICENSE +21 -0
- package/vendor/selection-hook/index.d.ts +517 -0
- package/vendor/selection-hook/index.js +662 -0
- package/vendor/selection-hook/package.json +10 -0
- package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// What the toolbar does in response to the mouse, the keyboard, the page and the Host. No native code
|
|
2
|
+
// here: main.mjs connects it to selection-hook, the overlay window and the Host channel.
|
|
3
|
+
|
|
4
|
+
const COPY_FEEDBACK_MS = 700
|
|
5
|
+
// Keys pressed right after the toolbar appears may be selection-hook's own simulated copy.
|
|
6
|
+
const KEY_DISMISS_GRACE_MS = 300
|
|
7
|
+
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'Fn'])
|
|
8
|
+
// Search engines read only the start of a query anyway.
|
|
9
|
+
const SEARCH_MAX_LENGTH = 2000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{
|
|
13
|
+
* overlay: Awaited<ReturnType<typeof import('./overlay.mjs').createOverlay>>,
|
|
14
|
+
* speech: { stop: () => void, toggle: (text: string) => void },
|
|
15
|
+
* send: (message: object) => void,
|
|
16
|
+
* log: (level: string, message: string, fields?: object) => void,
|
|
17
|
+
* writeClipboard: (text: string) => void,
|
|
18
|
+
* openUrl: (url: string) => void,
|
|
19
|
+
* positions: { invalid: number, fullSelection: number },
|
|
20
|
+
* idPrefix: string,
|
|
21
|
+
* now?: () => number,
|
|
22
|
+
* later?: (callback: () => void, ms: number) => unknown,
|
|
23
|
+
* }} options `positions` are selection-hook's INVALID_COORDINATE and PositionLevel.SEL_FULL.
|
|
24
|
+
* `idPrefix` must differ between worker processes, so a late answer never lands in a new panel.
|
|
25
|
+
*/
|
|
26
|
+
export function createController({
|
|
27
|
+
overlay, speech, send, log, writeClipboard, openUrl, positions, idPrefix,
|
|
28
|
+
now = Date.now, later = setTimeout,
|
|
29
|
+
}) {
|
|
30
|
+
let config = null
|
|
31
|
+
/** `text` goes to the model and the panel; `full` is what Copy, Read aloud and Search use. */
|
|
32
|
+
/** @type {{ id: number, text: string, full: string, app: string } | null} */
|
|
33
|
+
let selection = null
|
|
34
|
+
/** The skill the panel was opened with; the header's retry and language menu run it again. */
|
|
35
|
+
/** @type {{ skillId: string, kind: string, language?: string } | null} */
|
|
36
|
+
let panel = null
|
|
37
|
+
/** @type {{ requestId: string, skillId: string, kind: string, language?: string, question?: string, answer: string, history: object[] } | null} */
|
|
38
|
+
let active = null
|
|
39
|
+
let pinned = false
|
|
40
|
+
let shownAt = 0
|
|
41
|
+
const held = new Set()
|
|
42
|
+
// Whether the latest mouse press landed on the overlay. A gesture that starts there (dragging the
|
|
43
|
+
// window, selecting part of an answer) belongs to the overlay, even if it ends somewhere else.
|
|
44
|
+
let pressedOnOverlay = false
|
|
45
|
+
let nextSelection = 0
|
|
46
|
+
let nextRequest = 0
|
|
47
|
+
|
|
48
|
+
const valid = (point) => Boolean(point) && point.x !== positions.invalid && point.y !== positions.invalid
|
|
49
|
+
|
|
50
|
+
function configure(next) {
|
|
51
|
+
config = next
|
|
52
|
+
overlay.configure({ bar: next.skills.slice(0, next.toolbarSize), more: next.skills.slice(next.toolbarSize) })
|
|
53
|
+
if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onSelection(data) {
|
|
57
|
+
if (!config?.enabled || (config.trigger === 'modifier' && !held.has('Alt'))) return
|
|
58
|
+
const text = (data.text ?? '').trim()
|
|
59
|
+
if (!text || config.disabledApps.includes(data.programName)) return
|
|
60
|
+
// Dragging the overlay looks like a drag-selection to the hook, which then re-reads whatever is still
|
|
61
|
+
// selected in the app behind it. A gesture that began on the overlay never replaces it.
|
|
62
|
+
if (pressedOnOverlay && overlay.mode !== 'hidden') return
|
|
63
|
+
const anchor = anchorOf(data)
|
|
64
|
+
if (!anchor) return
|
|
65
|
+
if (overlay.mode !== 'hidden') log('debug', 'overlay replaced by a new selection', { mode: overlay.mode, bounds: overlay.bounds })
|
|
66
|
+
cancelActive()
|
|
67
|
+
speech.stop()
|
|
68
|
+
pinned = false
|
|
69
|
+
panel = null
|
|
70
|
+
overlay.setKeyable(false)
|
|
71
|
+
selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
|
|
72
|
+
shownAt = now()
|
|
73
|
+
overlay.showBar(anchor)
|
|
74
|
+
// The text stays here until the user picks an action; the Host only learns which app it came from.
|
|
75
|
+
send({ type: 'selection', app: data.programName })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function anchorOf(data) {
|
|
79
|
+
if (data.posLevel >= positions.fullSelection && valid(data.endBottom) && valid(data.startTop)) {
|
|
80
|
+
return { x: data.endBottom.x, y: data.endBottom.y, top: data.startTop.y, precise: true }
|
|
81
|
+
}
|
|
82
|
+
if (!valid(data.mousePosEnd)) return null
|
|
83
|
+
const start = valid(data.mousePosStart) ? data.mousePosStart : data.mousePosEnd
|
|
84
|
+
return { x: data.mousePosEnd.x, y: data.mousePosEnd.y, top: Math.min(start.y, data.mousePosEnd.y) - 10, precise: false }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onMouseDown(point) {
|
|
88
|
+
pressedOnOverlay = overlay.mode !== 'hidden' && overlay.contains(point)
|
|
89
|
+
if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onMouseWheel(point) {
|
|
93
|
+
if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function onKeyDown(key) {
|
|
97
|
+
if (MODIFIER_KEYS.has(key)) {
|
|
98
|
+
held.add(key)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (key === 'Escape') {
|
|
102
|
+
// The hook also sees keys typed into the panel. There the page handles Escape itself, and an
|
|
103
|
+
// input method may be using it to cancel a composition.
|
|
104
|
+
if (overlay.mode === 'panel' && overlay.isKey()) return
|
|
105
|
+
speech.stop()
|
|
106
|
+
dismiss({ force: true, reason: 'escape' })
|
|
107
|
+
} else if (overlay.mode === 'bar' && !held.has('Meta') && !held.has('Control') && now() - shownAt > KEY_DISMISS_GRACE_MS) {
|
|
108
|
+
// Typing elsewhere means the user has moved on; a shortcut such as ⌘C does not.
|
|
109
|
+
dismiss({ reason: 'typing' })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onKeyUp(key) {
|
|
114
|
+
held.delete(key)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onPage(message) {
|
|
118
|
+
switch (message?.type) {
|
|
119
|
+
case 'skill':
|
|
120
|
+
return runSkill(message.id)
|
|
121
|
+
case 'menu':
|
|
122
|
+
if (message.action !== 'disable-app' && message.action !== 'settings') return
|
|
123
|
+
if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
|
|
124
|
+
if (message.action === 'settings') send({ type: 'open-settings' })
|
|
125
|
+
return dismiss({ force: true, reason: `menu-${message.action}` })
|
|
126
|
+
case 'drag':
|
|
127
|
+
overlay.drag(message.phase, { x: message.x, y: message.y })
|
|
128
|
+
return
|
|
129
|
+
case 'retry':
|
|
130
|
+
if (message.requestId) {
|
|
131
|
+
// A failure notice retries its own request, and only while that request is the one on screen.
|
|
132
|
+
if (active?.requestId === message.requestId) {
|
|
133
|
+
startRequest(active.skillId, active.kind, { language: active.language, question: active.question, history: active.history })
|
|
134
|
+
}
|
|
135
|
+
} else if (panel?.kind === 'prompt') {
|
|
136
|
+
startRequest(panel.skillId, 'prompt', { language: panel.language, history: [] })
|
|
137
|
+
}
|
|
138
|
+
return
|
|
139
|
+
case 'language':
|
|
140
|
+
if (panel?.skillId === 'translate' && typeof message.code === 'string') {
|
|
141
|
+
panel.language = message.code
|
|
142
|
+
startRequest('translate', 'prompt', { language: message.code, history: [] })
|
|
143
|
+
}
|
|
144
|
+
return
|
|
145
|
+
case 'ask':
|
|
146
|
+
if (panel && typeof message.question === 'string' && message.question.trim()) {
|
|
147
|
+
startRequest('ask', 'ask', { question: message.question.trim().slice(0, 2000), history: conversation() })
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
case 'copy-result':
|
|
151
|
+
if (typeof message.text === 'string') writeClipboard(message.text)
|
|
152
|
+
return
|
|
153
|
+
case 'open-url':
|
|
154
|
+
if (typeof message.url === 'string') openUrl(message.url)
|
|
155
|
+
return
|
|
156
|
+
case 'pin':
|
|
157
|
+
pinned = Boolean(message.pinned)
|
|
158
|
+
return
|
|
159
|
+
case 'keyboard':
|
|
160
|
+
overlay.setKeyable(Boolean(message.needed))
|
|
161
|
+
return
|
|
162
|
+
case 'close':
|
|
163
|
+
speech.stop()
|
|
164
|
+
return dismiss({ force: true, reason: 'close-button' })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runSkill(id) {
|
|
169
|
+
const skill = config?.skills.find((candidate) => candidate.id === id)
|
|
170
|
+
if (!skill || !selection) return
|
|
171
|
+
switch (skill.kind) {
|
|
172
|
+
case 'prompt':
|
|
173
|
+
panel = { skillId: skill.id, kind: 'prompt' }
|
|
174
|
+
overlay.showPanel({ skill, text: selection.text, app: selection.app, languages: config.languages, translateTarget: config.translateTarget })
|
|
175
|
+
return startRequest(skill.id, 'prompt', { history: [] })
|
|
176
|
+
case 'ask':
|
|
177
|
+
cancelActive()
|
|
178
|
+
panel = { skillId: 'ask', kind: 'ask' }
|
|
179
|
+
active = { requestId: '', skillId: 'ask', kind: 'ask', answer: '', history: [] }
|
|
180
|
+
overlay.showPanel({ skill, text: selection.text, app: selection.app, languages: config.languages })
|
|
181
|
+
// The ask box needs keystrokes; the non-activating panel takes them without activating this process.
|
|
182
|
+
overlay.setKeyable(true)
|
|
183
|
+
return
|
|
184
|
+
case 'speak':
|
|
185
|
+
return speech.toggle(selection.full)
|
|
186
|
+
case 'copy': {
|
|
187
|
+
writeClipboard(selection.full)
|
|
188
|
+
overlay.flash('copy')
|
|
189
|
+
const copied = selection.id
|
|
190
|
+
later(() => {
|
|
191
|
+
if (selection?.id === copied && overlay.mode === 'bar') dismiss({ reason: 'copied' })
|
|
192
|
+
}, COPY_FEEDBACK_MS)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
case 'search':
|
|
196
|
+
openUrl(config.searchTemplate.replace('%s', encodeURIComponent(cut(selection.full, SEARCH_MAX_LENGTH))))
|
|
197
|
+
return dismiss({ force: true, reason: 'search' })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function startRequest(skillId, kind, { language, question, history }) {
|
|
202
|
+
if (!selection) return
|
|
203
|
+
cancelActive()
|
|
204
|
+
const requestId = `${idPrefix}-${selection.id}-${++nextRequest}`
|
|
205
|
+
active = { requestId, skillId, kind, language, question, answer: '', history }
|
|
206
|
+
overlay.begin(requestId, { question })
|
|
207
|
+
send({ type: 'run', requestId, skillId, text: selection.text, language, question, history })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The panel's finished turns, for a follow-up question. The first turn of a panel opened with
|
|
212
|
+
* another skill is that skill's answer; the Host shows the model what it was asked to do there.
|
|
213
|
+
*/
|
|
214
|
+
function conversation() {
|
|
215
|
+
if (!active) return []
|
|
216
|
+
const history = [...active.history]
|
|
217
|
+
if (active.answer) {
|
|
218
|
+
history.push(active.question
|
|
219
|
+
? { question: active.question, answer: active.answer }
|
|
220
|
+
: { skillId: active.skillId, language: active.language, answer: active.answer })
|
|
221
|
+
}
|
|
222
|
+
return history
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onResult({ requestId, chunk, done, error }) {
|
|
226
|
+
if (!active || active.requestId !== requestId) return
|
|
227
|
+
if (chunk) {
|
|
228
|
+
active.answer += chunk
|
|
229
|
+
overlay.append(requestId, chunk)
|
|
230
|
+
}
|
|
231
|
+
if (error) overlay.fail(requestId, error)
|
|
232
|
+
else if (done) overlay.done(requestId)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cancelActive() {
|
|
236
|
+
if (active?.requestId) send({ type: 'cancel', requestId: active.requestId })
|
|
237
|
+
active = null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function dismiss({ force = false, reason = 'other', point } = {}) {
|
|
241
|
+
if (overlay.mode === 'hidden' || (pinned && !force)) return
|
|
242
|
+
// Positions only, never text: enough to tell a misplaced hit test from an intended dismissal.
|
|
243
|
+
log('debug', 'overlay dismissed', { reason, mode: overlay.mode, point, bounds: overlay.bounds })
|
|
244
|
+
cancelActive()
|
|
245
|
+
pinned = false
|
|
246
|
+
pressedOnOverlay = false
|
|
247
|
+
selection = null
|
|
248
|
+
panel = null
|
|
249
|
+
overlay.setKeyable(false)
|
|
250
|
+
overlay.hide()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
|
|
257
|
+
export function cut(text, max) {
|
|
258
|
+
if (text.length <= max) return text
|
|
259
|
+
const end = /[\uD800-\uDBFF]/.test(text[max - 1]) ? max - 1 : max
|
|
260
|
+
return text.slice(0, end)
|
|
261
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Selection worker: a child process of the DSH Host. selection-hook watches the mouse and reads the
|
|
2
|
+
// selection; the overlay shows the toolbar and results; controller.mjs decides what happens.
|
|
3
|
+
// Model calls happen in the Host, never here.
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { RESTART_NOW } from '../shared/protocol.mjs'
|
|
6
|
+
import { createController } from './controller.mjs'
|
|
7
|
+
import { openUrl } from './open-url.mjs'
|
|
8
|
+
import { createOverlay } from './overlay.mjs'
|
|
9
|
+
import { createSpeech } from './speech.mjs'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
const SelectionHook = require('../../vendor/selection-hook/index.js')
|
|
13
|
+
|
|
14
|
+
const PERMISSION_POLL_MS = 1000
|
|
15
|
+
|
|
16
|
+
const send = (message) => process.send?.(message)
|
|
17
|
+
const log = (level, message, fields) => send({ type: 'log', level, message, fields })
|
|
18
|
+
const hook = new SelectionHook()
|
|
19
|
+
// A bug in one handler must not take the toolbar down: it is logged and the next event still works.
|
|
20
|
+
const guarded = (name, handler) => (...args) => {
|
|
21
|
+
try {
|
|
22
|
+
handler(...args)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
log('error', `${name} failed`, { error: String(error?.stack ?? error).slice(0, 800) })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let config = null
|
|
29
|
+
/** @type {Awaited<ReturnType<typeof createOverlay>> | null} */
|
|
30
|
+
let overlay = null
|
|
31
|
+
/** @type {ReturnType<typeof createController> | null} */
|
|
32
|
+
let controller = null
|
|
33
|
+
let speech = null
|
|
34
|
+
let altDown = false
|
|
35
|
+
|
|
36
|
+
process.on('disconnect', () => shutdown(0))
|
|
37
|
+
process.on('message', (message) => {
|
|
38
|
+
if (message?.type === 'config') applyConfig(message.config)
|
|
39
|
+
else if (message?.type === 'result') guarded('result', () => controller?.onResult(message))()
|
|
40
|
+
else if (message?.type === 'shutdown') shutdown(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await new Promise((resolve) => {
|
|
44
|
+
const waitForConfig = setInterval(() => {
|
|
45
|
+
if (!config) return
|
|
46
|
+
clearInterval(waitForConfig)
|
|
47
|
+
resolve()
|
|
48
|
+
}, 20)
|
|
49
|
+
send({ type: 'hello' })
|
|
50
|
+
})
|
|
51
|
+
await waitForAccessibility()
|
|
52
|
+
|
|
53
|
+
speech = createSpeech({ log, onChange: (speaking) => overlay?.setSpeaking(speaking) })
|
|
54
|
+
overlay = await createOverlay({ onMessage: guarded('page message', (message) => controller?.onPage(message)), log })
|
|
55
|
+
controller = createController({
|
|
56
|
+
overlay,
|
|
57
|
+
speech,
|
|
58
|
+
send,
|
|
59
|
+
log,
|
|
60
|
+
writeClipboard: (text) => hook.writeToClipboard(text),
|
|
61
|
+
openUrl: (url) => openUrl(url, log),
|
|
62
|
+
positions: { invalid: SelectionHook.INVALID_COORDINATE, fullSelection: SelectionHook.PositionLevel.SEL_FULL },
|
|
63
|
+
idPrefix: String(process.pid),
|
|
64
|
+
})
|
|
65
|
+
applyConfig(config)
|
|
66
|
+
|
|
67
|
+
hook.on('text-selection', guarded('selection', (data) => controller.onSelection(data)))
|
|
68
|
+
hook.on('mouse-down', guarded('mouse-down', (point) => controller.onMouseDown(point)))
|
|
69
|
+
hook.on('mouse-wheel', guarded('mouse-wheel', (point) => controller.onMouseWheel(point)))
|
|
70
|
+
hook.on('key-down', guarded('key-down', ({ uniKey }) => {
|
|
71
|
+
if (uniKey === 'Alt' && !altDown) {
|
|
72
|
+
altDown = true
|
|
73
|
+
applyHookFilters(config)
|
|
74
|
+
}
|
|
75
|
+
controller.onKeyDown(uniKey)
|
|
76
|
+
}))
|
|
77
|
+
hook.on('key-up', guarded('key-up', ({ uniKey }) => {
|
|
78
|
+
if (uniKey === 'Alt' && altDown) {
|
|
79
|
+
altDown = false
|
|
80
|
+
applyHookFilters(config)
|
|
81
|
+
}
|
|
82
|
+
controller.onKeyUp(uniKey)
|
|
83
|
+
}))
|
|
84
|
+
hook.on('error', (error) => log('warn', 'selection-hook error', { error: String(error?.message ?? error) }))
|
|
85
|
+
|
|
86
|
+
const hookStarted = hook.start({ debug: false, enableClipboard: config.clipboardFallback })
|
|
87
|
+
applyHookFilters(config)
|
|
88
|
+
send({ type: 'ready', hookStarted, platform: process.platform, pid: process.pid })
|
|
89
|
+
|
|
90
|
+
function applyConfig(next) {
|
|
91
|
+
config = next
|
|
92
|
+
if (!controller) return
|
|
93
|
+
controller.configure(next)
|
|
94
|
+
applyHookFilters(next)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The hook reads every selection before the controller sees it, and a read can be a simulated ⌘C in
|
|
98
|
+
// the app. While the toolbar cannot appear (turned off, or ⌥ not held), an empty include list makes
|
|
99
|
+
// the native engine skip the read entirely.
|
|
100
|
+
function applyHookFilters(next) {
|
|
101
|
+
const reading = next.enabled && (next.trigger !== 'modifier' || altDown)
|
|
102
|
+
if (next.clipboardFallback && reading) hook.enableClipboard()
|
|
103
|
+
else hook.disableClipboard()
|
|
104
|
+
if (!reading) hook.setGlobalFilterMode(SelectionHook.FilterMode.INCLUDE_LIST, [])
|
|
105
|
+
else if (next.disabledApps.length) hook.setGlobalFilterMode(SelectionHook.FilterMode.EXCLUDE_LIST, next.disabledApps)
|
|
106
|
+
else hook.setGlobalFilterMode(SelectionHook.FilterMode.DEFAULT, [])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Without the accessibility grant macOS delivers no events to selection-hook. The grant belongs to the
|
|
110
|
+
// responsible process: DeepSeek Harness inside DSH, or the terminal app when running the dev script.
|
|
111
|
+
async function waitForAccessibility() {
|
|
112
|
+
if (process.platform !== 'darwin') return
|
|
113
|
+
if (hook.macIsProcessTrusted()) {
|
|
114
|
+
send({ type: 'permission', trusted: true })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const prompt = !process.argv.includes('--no-prompt')
|
|
118
|
+
if (prompt) hook.macRequestProcessTrust()
|
|
119
|
+
send({ type: 'permission', trusted: false, prompted: prompt })
|
|
120
|
+
await new Promise((resolve) => {
|
|
121
|
+
const timer = setInterval(() => {
|
|
122
|
+
if (!hook.macIsProcessTrusted()) return
|
|
123
|
+
clearInterval(timer)
|
|
124
|
+
resolve()
|
|
125
|
+
}, PERMISSION_POLL_MS)
|
|
126
|
+
})
|
|
127
|
+
send({ type: 'permission', trusted: true })
|
|
128
|
+
// Event taps created before the grant stay deaf; a fresh process starts listening right away.
|
|
129
|
+
hook.cleanup()
|
|
130
|
+
process.exit(RESTART_NOW)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function shutdown(code) {
|
|
134
|
+
try {
|
|
135
|
+
speech?.stop()
|
|
136
|
+
hook.stop()
|
|
137
|
+
hook.cleanup()
|
|
138
|
+
} catch {
|
|
139
|
+
// Best effort on the way out.
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
overlay?.dispose()
|
|
143
|
+
} catch {
|
|
144
|
+
// Same.
|
|
145
|
+
}
|
|
146
|
+
process.exit(code)
|
|
147
|
+
}
|