@yfwu2020/dsh-selection-explain 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yfwu2020/dsh-selection-explain",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build": "bash scripts/build.sh",
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
- "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-escape-focus.mjs && node scripts/test-effort-retry.mjs && node scripts/test-effort-map.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/test-mic-idle.mjs && node scripts/smoke-voice.mjs",
46
+ "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-escape-focus.mjs && node scripts/test-effort-retry.mjs && node scripts/test-effort-map.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/test-mic-idle.mjs && node scripts/test-mic-build.mjs && node scripts/smoke-voice.mjs",
47
47
  "test:effort": "node scripts/test-effort-retry.mjs",
48
48
  "test:effort-map": "node scripts/test-effort-map.mjs",
49
49
  "test:transcript": "node scripts/test-transcript.mjs",
@@ -1504,8 +1504,57 @@ async function main() {
1504
1504
  hook.open('模型菜单勾选', '', '菜单冒烟')
1505
1505
  await waitFor(function () { return hook.state().phase === 'done' }, 8000)
1506
1506
  await hook.loadModels(true)
1507
+ var modelNodes = hook.modelNodes()
1508
+ // DSH 按属性判定弹层,不检查 CSS 可见性(Issue #1)。
1509
+ var modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]'
1510
+ function modelMenuOwnsShortcuts() {
1511
+ return Array.from(document.querySelectorAll(modalSelector)).includes(modelNodes.menu)
1512
+ }
1513
+ function checkModelMenuClosed(reason) {
1514
+ check(reason + ':模型菜单不再占用 DSH 快捷键',
1515
+ !modelMenuOwnsShortcuts() && modelNodes.menu.getAttribute('data-open') !== '1' &&
1516
+ modelNodes.pill.getAttribute('aria-expanded') === 'false')
1517
+ }
1518
+ checkModelMenuClosed('初始收起')
1519
+ // 官方 useModalLayer 在 document 冒泡阶段处理 Esc;插件 capture 必须让行。
1520
+ function checkHostEscapeFirst(target, label) {
1521
+ var hostSettings = document.createElement('div')
1522
+ hostSettings.setAttribute('role', 'dialog')
1523
+ hostSettings.setAttribute('aria-modal', 'true')
1524
+ hostSettings.setAttribute('data-shortcut-modal', 'settings')
1525
+ var hostInput = document.createElement('input')
1526
+ hostSettings.appendChild(hostInput)
1527
+ document.body.appendChild(hostSettings)
1528
+ function dismissHost(event) {
1529
+ if (event.key !== 'Escape' || event.defaultPrevented) return
1530
+ event.preventDefault()
1531
+ hostSettings.remove()
1532
+ }
1533
+ document.addEventListener('keydown', dismissHost)
1534
+ try {
1535
+ var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
1536
+ ;(target || hostInput).dispatchEvent(event)
1537
+ check(label + ':Esc 到达官方浮层并只关闭官方设置',
1538
+ event.defaultPrevented && !hostSettings.isConnected && panel.style.display === 'flex')
1539
+ } finally {
1540
+ document.removeEventListener('keydown', dismissHost)
1541
+ hostSettings.remove()
1542
+ }
1543
+ }
1544
+ var escapeModeFrame = document.createElement('div')
1545
+ escapeModeFrame.setAttribute('data-dsh-full-view', '')
1546
+ escapeModeFrame.setAttribute('data-dsh-active-floating-window', 'explanation')
1547
+ for (var fullViewMode of [false, true]) {
1548
+ if (fullViewMode) document.body.appendChild(escapeModeFrame)
1549
+ var modeLabel = fullViewMode ? '完整视图' : '普通分栏'
1550
+ checkHostEscapeFirst(null, modeLabel + '/焦点在官方设置')
1551
+ checkHostEscapeFirst(document.querySelector('.dsh-sel-askbox'), modeLabel + '/焦点仍在解读输入框')
1552
+ }
1553
+ escapeModeFrame.remove()
1507
1554
  hook.openModel()
1508
1555
  await sleep(120)
1556
+ check('模型菜单展开时占用 DSH 快捷键',
1557
+ modelMenuOwnsShortcuts() && modelNodes.pill.getAttribute('aria-expanded') === 'true')
1509
1558
  var menu = hook.modelNodes().menu
1510
1559
  var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
1511
1560
  check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
@@ -1529,7 +1578,24 @@ async function main() {
1529
1578
  var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
1530
1579
  check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
1531
1580
  !!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
1581
+
1582
+ checkHostEscapeFirst(modelNodes.menu, '模型菜单仍展开')
1583
+ check('官方设置关闭时保留插件模型菜单', modelNodes.menu.getAttribute('data-open') === '1')
1584
+
1585
+ clickOn(modelNodes.pill)
1586
+ checkModelMenuClosed('再次点击胶囊')
1587
+ hook.openModel()
1588
+ modelNodes.menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1589
+ checkModelMenuClosed('按 Esc')
1590
+ hook.openModel()
1591
+ document.getElementById('docp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
1592
+ checkModelMenuClosed('点击菜单外部')
1593
+ hook.openModel()
1594
+ clickOn(modelNodes.menu.querySelector('.dsh-sel-pickerrow'))
1595
+ checkModelMenuClosed('选择模型')
1596
+ hook.openModel()
1532
1597
  hook.close() // 关窗会顺手收起模型菜单
1598
+ checkModelMenuClosed('关闭解读面板')
1533
1599
  await sleep(30)
1534
1600
  }
1535
1601
 
@@ -7,12 +7,14 @@ const start = source.indexOf(' var offKeyDown = listen(document,')
7
7
  const end = source.indexOf('\n\n var offSettingsOpen', start)
8
8
  assert.ok(start >= 0 && end > start)
9
9
  const ask = {}, historyInput = {}, outside = {}, expandedChat = { closest: selector => selector.startsWith('[data-dsh-floating-chat]') ? {} : null }, compactChat = { closest: () => null }, nativeMenu = { closest: selector => selector.startsWith('[role=') ? {} : null }
10
- let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true
10
+ let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true, hostModalOpen = false
11
11
  const context = vm.createContext({
12
12
  panelOpen: true, settingsOpen: false,
13
13
  panel: { contains: node => node === ask },
14
14
  historyList: { contains: node => node === historyInput },
15
- document: { querySelector: selector => selector === '[data-dsh-full-view]' && fullView
15
+ document: { querySelector: selector => selector === '[role="dialog"][aria-modal="true"]'
16
+ ? hostModalOpen ? {} : null
17
+ : selector === '[data-dsh-full-view]' && fullView
16
18
  ? { getAttribute: () => activeWindow }
17
19
  : selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
18
20
  listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
@@ -27,7 +29,22 @@ function escape(target, extra = {}) {
27
29
  const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
28
30
  handler(event)
29
31
  }
30
- function reset() { calls = []; activeWindow = null; chatExpanded = true; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
32
+ function reset() { calls = []; activeWindow = null; chatExpanded = true; hostModalOpen = false; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
33
+ for (const mode of [true, false]) {
34
+ for (const target of [ask, historyInput, outside, expandedChat]) {
35
+ reset(); fullView = mode; hostModalOpen = true; activeWindow = 'explanation'
36
+ escape(target)
37
+ assert.deepEqual(calls, [], '官方模态设置浮层优先消费 Esc,不受视图模式、焦点或最后操作窗口影响')
38
+ assert.equal(context.panelOpen, true)
39
+ }
40
+ }
41
+ reset(); fullView = true; hostModalOpen = true; context.settingsOpen = true; escape(ask)
42
+ assert.deepEqual(calls, [], '官方设置打开时不关闭插件设置抽屉')
43
+ assert.equal(context.settingsOpen, true)
44
+ reset(); hostModalOpen = true; context.captureRow.getAttribute = () => '1'; escape(ask)
45
+ assert.deepEqual(calls, [], '官方设置打开时不取消插件录音')
46
+ hostModalOpen = false; escape(ask)
47
+ assert.deepEqual(calls, ['prevent', 'stop', 'voice'], '官方设置退出后,下一次 Esc 恢复插件的内层退出顺序')
31
48
  reset(); activeWindow = 'chat'; escape(ask)
32
49
  assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
33
50
  reset(); activeWindow = 'explanation'; escape(expandedChat)
@@ -63,4 +80,22 @@ for (const target of [ask, historyInput]) {
63
80
  }
64
81
  reset(); fullView = false; escape(outside)
65
82
  assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
66
- console.log('PASS focused Esc: independent windows, IME, nested settings, voice, history and split-mode behavior')
83
+
84
+ // 语音卡另有一个更早注册的 capture 监听,也必须给官方浮层让出 Esc。
85
+ const panelHandler = handler
86
+ const voiceStart = source.indexOf(' var offVoiceEscape = listen(document,')
87
+ const voiceEnd = source.indexOf('\n }, true)', voiceStart) + '\n }, true)'.length
88
+ assert.ok(voiceStart >= 0 && voiceEnd > voiceStart)
89
+ context.vcardState = { open: true, suppress: false }
90
+ context.closeVCard = () => { calls.push('vcard'); context.vcardState.open = false }
91
+ new vm.Script(source.slice(voiceStart, voiceEnd)).runInContext(context)
92
+ const voiceHandler = handler
93
+ handler = panelHandler
94
+ reset(); hostModalOpen = true
95
+ voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
96
+ assert.deepEqual(calls, [], '官方设置打开时,语音卡不抢先消费 Esc')
97
+ assert.equal(context.vcardState.open, true)
98
+ hostModalOpen = false
99
+ voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
100
+ assert.deepEqual(calls, ['prevent', 'stop', 'vcard'], '官方设置退出后,Esc 可以关闭语音卡')
101
+ console.log('PASS focused Esc: host modals, independent windows, IME, nested settings, voice, history and split-mode behavior')
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 「就地编译探针」:npm / Release 装下来的包里**没有** `lib/native/mic-probe`
3
+ * —— CI 跑在 ubuntu 上,`build.sh` 里那一步会被跳过(日志原话:
4
+ * "跳过原生探针(非 macOS 或没有 clang)"),所以包里只有 `native/mic-probe.c` 源码。
5
+ *
6
+ * 后果(2026-10-11 用户实测):从 npm 装到别的 Mac 上,豆包开麦永远不弹卡 ——
7
+ * host 直接报 `probe-missing`。修法是第一次有人问麦克风时**用 clang 现场编一份**,
8
+ * 按源码 hash 缓存进数据目录。
9
+ *
10
+ * 这条用例就复现"装包的机器":把随包的二进制**暂时挪开**、数据目录指向一个全新的
11
+ * DSH_HOME,然后问一次 /mic —— 应该 available:true,而且编出来的二进制真能跑(--once)。
12
+ *
13
+ * 只在 macOS + 有 clang 时有意义;其它平台 SKIP(与真浏览器冒烟同一套约定:跑不了就跳过)。
14
+ *
15
+ * 用法:node scripts/test-mic-build.mjs
16
+ */
17
+ import { createServer } from 'node:http'
18
+ import { existsSync } from 'node:fs'
19
+ import { mkdtemp, readdir, rename, rm } from 'node:fs/promises'
20
+ import { spawnSync } from 'node:child_process'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ if (process.platform !== 'darwin' || spawnSync('clang', ['--version']).status !== 0) {
26
+ console.log('SKIP 就地编译探针是 macOS 专用(且需要 clang)—— 本机跳过')
27
+ process.exit(0)
28
+ }
29
+
30
+ const root = fileURLToPath(new URL('..', import.meta.url))
31
+ const packaged = join(root, 'lib/native/mic-probe')
32
+ const stash = packaged + '.probe-build-test'
33
+ const home = await mkdtemp(join(tmpdir(), 'dsh-mic-build-'))
34
+ // 数据目录与"注入了假探针"那条路都要避开:前者要全新,后者要显式删除(否则测不到编译)
35
+ process.env.DSH_HOME = home
36
+ delete process.env.DSH_SEL_MIC_PROBE
37
+
38
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
39
+ let passed = 0
40
+ let failed = 0
41
+ function check(label, ok, detail) {
42
+ if (ok) {
43
+ passed += 1
44
+ console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`)
45
+ } else {
46
+ failed += 1
47
+ console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`)
48
+ }
49
+ }
50
+
51
+ let moved = false
52
+ if (existsSync(packaged)) {
53
+ await rename(packaged, stash)
54
+ moved = true
55
+ }
56
+
57
+ const routes = new Map()
58
+ const disposers = []
59
+ const ctx = {
60
+ llm: { listProviders: () => [] },
61
+ get: () => undefined,
62
+ on: () => () => {},
63
+ effect: (fn) => {
64
+ const dispose = fn()
65
+ disposers.push(dispose)
66
+ return dispose
67
+ },
68
+ logger: { info() {}, warn() {} },
69
+ webServer: {
70
+ register: (route) => {
71
+ routes.set(route.path, route.handler)
72
+ return () => routes.delete(route.path)
73
+ },
74
+ },
75
+ }
76
+
77
+ try {
78
+ // ⚠️ 必须在"挪开二进制"之后才 import:探针路径是在模块求值时算的
79
+ const { apply } = await import('../lib/index.js')
80
+ apply(ctx, {})
81
+ const server = createServer((req, res) => {
82
+ const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
83
+ if (!handler) {
84
+ res.writeHead(404)
85
+ res.end()
86
+ return
87
+ }
88
+ Promise.resolve(handler(req, res)).catch(() => {
89
+ if (!res.headersSent) res.writeHead(500)
90
+ res.end()
91
+ })
92
+ })
93
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
94
+ const origin = 'http://127.0.0.1:' + server.address().port
95
+ const dataDir = join(home, 'selection-explain')
96
+
97
+ check('(前提)装包的样子:随包的二进制不在,只剩源码', !existsSync(packaged) && existsSync(join(root, 'native/mic-probe.c')), '')
98
+
99
+ const payload = await (await fetch(`${origin}/selection-explain/api/mic`)).json()
100
+ check(
101
+ '装包的机器上第一次问麦克风 → 就地编出来,状态是 available(不再 probe-missing)',
102
+ payload.ok === true && payload.available === true && payload.reason === '',
103
+ JSON.stringify({ available: payload.available, reason: payload.reason }),
104
+ )
105
+
106
+ const files = existsSync(dataDir) ? await readdir(dataDir) : []
107
+ const compiled = files.filter((f) => /^mic-probe-[0-9a-f]{8}$/.test(f))
108
+ check(
109
+ '编出来的探针按源码 hash 缓存进数据目录(源码改了会自然重建)',
110
+ compiled.length === 1,
111
+ JSON.stringify(files),
112
+ )
113
+
114
+ if (compiled.length === 1) {
115
+ const run = spawnSync(join(dataDir, compiled[0]), ['--once'], { encoding: 'utf8', timeout: 20_000 })
116
+ let parsed = null
117
+ try {
118
+ parsed = JSON.parse(String(run.stdout || '').trim().split('\n').pop() || 'null')
119
+ } catch {
120
+ /* 保持 null */
121
+ }
122
+ check(
123
+ '编出来的二进制真能跑(--once 输出一行合法 JSON)',
124
+ run.status === 0 && !!parsed && typeof parsed.capturing === 'boolean',
125
+ JSON.stringify({ status: run.status, stdout: String(run.stdout || '').trim().slice(0, 80) }),
126
+ )
127
+ } else {
128
+ check('编出来的二进制真能跑(--once 输出一行合法 JSON)', false, '没有找到编出来的探针')
129
+ }
130
+
131
+ server.close()
132
+ } finally {
133
+ for (const dispose of disposers) {
134
+ try {
135
+ dispose()
136
+ } catch {
137
+ /* 忽略 */
138
+ }
139
+ }
140
+ await sleep(300)
141
+ if (moved) await rename(stash, packaged)
142
+ await rm(home, { recursive: true, force: true })
143
+ }
144
+
145
+ console.log(`\n探针就地编译:${passed} 通过 / ${failed} 失败`)
146
+ process.exit(failed ? 1 : 0)
@@ -2648,7 +2648,7 @@ window.__ModuleLoader__.load({
2648
2648
  modelPill.appendChild(modelPillTier)
2649
2649
  modelPill.appendChild(el('span', 'dsh-sel-picker-caret', '▼'))
2650
2650
  var modelMenu = el('div', 'dsh-sel-pickermenu')
2651
- modelMenu.setAttribute('role', 'menu')
2651
+ // role 随开合同步:DSH 按 [role="menu"] 判定弹层,不检查 CSS 可见性。
2652
2652
 
2653
2653
  // ── 录音行(与主会话 ui-voice-input 同一套结构)──
2654
2654
  // [✕ 取消] [实时波形 / 状态文案] [■ 停止 或 行内动作] …… [↑ 发送(原位不动)]
@@ -9484,6 +9484,7 @@ window.__ModuleLoader__.load({
9484
9484
  */
9485
9485
  var offVoiceEscape = listen(document, 'keydown', function (event) {
9486
9486
  if (event.key !== 'Escape' || !vcardState.open || event.defaultPrevented) return
9487
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
9487
9488
  event.preventDefault()
9488
9489
  event.stopPropagation()
9489
9490
  vcardState.suppress = true
@@ -9936,11 +9937,13 @@ window.__ModuleLoader__.load({
9936
9937
 
9937
9938
  function closeModelMenu() {
9938
9939
  modelMenu.setAttribute('data-open', '0')
9940
+ modelMenu.removeAttribute('role')
9939
9941
  modelPill.setAttribute('aria-expanded', 'false')
9940
9942
  }
9941
9943
 
9942
9944
  function openModelMenu() {
9943
9945
  modelMenu.setAttribute('data-open', '1')
9946
+ modelMenu.setAttribute('role', 'menu')
9944
9947
  modelPill.setAttribute('aria-expanded', 'true')
9945
9948
  renderModelMenu()
9946
9949
  loadModelCatalog(false).then(function () {
@@ -9962,7 +9965,7 @@ window.__ModuleLoader__.load({
9962
9965
  event.stopPropagation() // 菜单内部点击不要触发"点到外面"
9963
9966
  })
9964
9967
  var offModelKeys = listen(modelMenu, 'keydown', function (event) {
9965
- if (event.key === 'Escape') closeModelMenu()
9968
+ if (event.key === 'Escape' && !document.querySelector('[role="dialog"][aria-modal="true"]')) closeModelMenu()
9966
9969
  })
9967
9970
 
9968
9971
  // —— 网页模式开关 ——
@@ -10140,6 +10143,9 @@ window.__ModuleLoader__.load({
10140
10143
 
10141
10144
  var offKeyDown = listen(document, 'keydown', function (event) {
10142
10145
  if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
10146
+ // 官方模态浮层的 Esc 在冒泡阶段处理;capture 不消费,先让官方设置等浮层退出。
10147
+ // 插件面板和设置抽屉是非模态 dialog,不带 aria-modal,不会命中这里。
10148
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
10143
10149
  // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
10144
10150
  var fullViewFrame = document.querySelector('[data-dsh-full-view]')
10145
10151
  if (fullViewFrame) {
package/src/index.ts CHANGED
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import { createHash } from 'node:crypto'
14
14
  import { lookup } from 'node:dns/promises'
15
- import { spawn } from 'node:child_process'
15
+ import { spawn, spawnSync } from 'node:child_process'
16
16
  import type { ChildProcess } from 'node:child_process'
17
- import { existsSync } from 'node:fs'
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
18
18
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
19
19
  import { homedir } from 'node:os'
20
20
  import { join, resolve, sep } from 'node:path'
@@ -133,9 +133,83 @@ const MIC_IDLE_CHECK_MS = Number(process.env.DSH_SEL_MIC_CHECK_MS || 5000)
133
133
  */
134
134
  const MIC_WATCH_MS = Number(process.env.DSH_SEL_MIC_WATCH_MS || 125)
135
135
 
136
- /** 探针路径:lib/native/mic-probe(与编译产物 lib/index.js 同级;测试可注入假的)。 */
137
- const MIC_PROBE =
138
- process.env.DSH_SEL_MIC_PROBE || fileURLToPath(new URL('native/mic-probe', import.meta.url))
136
+ /** 探针二进制:随包构建出来的那个(与编译产物 lib/index.js 同级;测试可注入假的)。 */
137
+ const MIC_PROBE = fileURLToPath(new URL('native/mic-probe', import.meta.url))
138
+ /** 探针源码:发布包里**一定有它**(`files` 含 native/),二进制则不一定(见下)。 */
139
+ const MIC_PROBE_SRC = fileURLToPath(new URL('../native/mic-probe.c', import.meta.url))
140
+
141
+ /** 解析结果缓存:'' = 解析过了但没有可用探针(reason 说明为什么)。 */
142
+ let micProbeResolved: string | null = null
143
+ let micProbeReason = ''
144
+
145
+ /** 插件自己的数据目录(历史 / 记忆 / 就地编译出来的探针都放这儿)。 */
146
+ function pluginDataDir(): string {
147
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain')
148
+ }
149
+
150
+ /**
151
+ * 找到探针二进制 —— 找不到就**现场编一份**。
152
+ *
153
+ * 为什么必须运行时编译:CI 跑在 ubuntu 上,`build.sh` 里的原生探针那一步会被跳过
154
+ * (日志原话:"跳过原生探针(非 macOS 或没有 clang)"),于是 npm / Release 装下来的包里
155
+ * **只有 `native/mic-probe.c` 源码、没有 `lib/native/mic-probe`** —— macOS 用户装上就是
156
+ * `probe-missing`,豆包开麦永远不弹卡(2026-10-11 用户在另一台 Mac 上实测到的就是这个)。
157
+ *
158
+ * 顺序:环境变量覆盖(测试用)→ 随包构建的 → 数据目录里按**源码 hash** 缓存的那份
159
+ * (没有就 clang 编一次;源码改了就自然重建)→ 都没有则如实报原因。
160
+ * 编译失败不算致命:其余功能一律不受影响(和"没装识别服务"一个待遇)。
161
+ */
162
+ function resolveMicProbe(): string {
163
+ if (micProbeResolved !== null) return micProbeResolved
164
+ const override = process.env.DSH_SEL_MIC_PROBE
165
+ if (override) {
166
+ micProbeResolved = existsSync(override) ? override : ''
167
+ if (!micProbeResolved) micProbeReason = 'probe-missing'
168
+ return micProbeResolved
169
+ }
170
+ if (existsSync(MIC_PROBE)) {
171
+ micProbeResolved = MIC_PROBE
172
+ return micProbeResolved
173
+ }
174
+ if (!existsSync(MIC_PROBE_SRC)) {
175
+ micProbeResolved = ''
176
+ micProbeReason = 'probe-missing'
177
+ return micProbeResolved
178
+ }
179
+ let tag = 'v1'
180
+ try {
181
+ tag = createHash('sha1').update(readFileSync(MIC_PROBE_SRC)).digest('hex').slice(0, 8)
182
+ } catch {
183
+ /* 读不了源码就按固定名缓存 */
184
+ }
185
+ const built = join(pluginDataDir(), `mic-probe-${tag}`)
186
+ if (existsSync(built)) {
187
+ micProbeResolved = built
188
+ return micProbeResolved
189
+ }
190
+ try {
191
+ mkdirSync(pluginDataDir(), { recursive: true })
192
+ const result = spawnSync(
193
+ 'clang',
194
+ ['-O2', '-framework', 'CoreAudio', '-framework', 'CoreFoundation', MIC_PROBE_SRC, '-o', built],
195
+ { timeout: 60_000 },
196
+ )
197
+ if (result.status === 0 && existsSync(built)) {
198
+ try {
199
+ chmodSync(built, 0o755)
200
+ } catch {
201
+ /* 权限位设不上也还能试跑 */
202
+ }
203
+ micProbeResolved = built
204
+ return micProbeResolved
205
+ }
206
+ } catch {
207
+ /* 下面统一给失败原因 */
208
+ }
209
+ micProbeResolved = ''
210
+ micProbeReason = 'probe-build-failed'
211
+ return micProbeResolved
212
+ }
139
213
 
140
214
  /**
141
215
  * 懒启动探针(第一次有人来问才 spawn)。
@@ -151,12 +225,13 @@ function ensureMicProbe(): void {
151
225
  micState.reason = 'not-macos'
152
226
  return
153
227
  }
154
- if (!existsSync(MIC_PROBE)) {
155
- micState.reason = 'probe-missing'
228
+ const probe = resolveMicProbe()
229
+ if (!probe) {
230
+ micState.reason = micProbeReason || 'probe-missing'
156
231
  return
157
232
  }
158
233
  try {
159
- const child = spawn(MIC_PROBE, ['--watch', String(MIC_WATCH_MS)], {
234
+ const child = spawn(probe, ['--watch', String(MIC_WATCH_MS)], {
160
235
  stdio: ['ignore', 'pipe', 'ignore'],
161
236
  })
162
237
  micChild = child