@mzzsfy/dsh-turn-notify 0.9.0 → 0.10.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-turn-notify
2
2
 
3
- DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM 五种通道,多窗口只响一次。
3
+ DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM、宿主桌面通知六种通道,多窗口只响一次。
4
4
 
5
5
  ## 快速上手
6
6
 
@@ -8,7 +8,7 @@ DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/
8
8
  dsh plugin --profile web add @mzzsfy/dsh-turn-notify
9
9
  ```
10
10
 
11
- 安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook 与 IM 填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
11
+ 安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook、IM 与宿主桌面通知填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送);想在宿主机桌面弹系统通知(浏览器全关也送达),见 [宿主桌面通知](#宿主桌面通知)。
12
12
 
13
13
  ## 六类通知事件
14
14
 
@@ -65,7 +65,7 @@ dsh plugin --profile web add @mzzsfy/dsh-turn-notify
65
65
 
66
66
  ## 事件→通道路由
67
67
 
68
- 六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im`。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
68
+ 六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im` / `host`(宿主桌面通知)。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
69
69
 
70
70
  ```yaml
71
71
  turn-notify:
@@ -80,6 +80,15 @@ turn-notify:
80
80
 
81
81
  在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。URL 原文随配置响应回显、保存即提交输入框内容(清空并保存即禁用),测试按钮返回真实投递结果。
82
82
 
83
+ ## 宿主桌面通知
84
+
85
+ 开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10 秒,失败即弃不重试,不影响其余通道。去重方向为**浏览器优先**:本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,宿主让位,正常路径同机零重复;本机浏览器离场(标签页全关或仅远程浏览器在线)时宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与网络抖动),宁可重复不可漏。两个开关判定一致,任一开启即生效:
86
+
87
+ - **宿主机弹桌面通知**:启用宿主通知通道(浏览器优先去重内建)。
88
+ - **仅当本机无浏览器接收时补位**:与总开关等效,作为显式的补位语义表达保留。
89
+
90
+ "本机浏览器在场"按投影长轮询记账:回环来源 + 续传 cursor + 同源形态的 GET 在服务端挂起期间计为在途,断连(标签页关闭/请求中止)经连接关闭事件即时出账,最近活动在 2 秒认可窗口内仍算在场——优雅断连的盲区不超过 2 秒;异常断连(睡眠、强杀、断网)服务端无感知,受长轮询挂起上限约束,最坏约 25 秒后自愈出账。浏览器在场但本机呈现偏好全关(声音/弹窗/页内全关或系统通知未授权)时,宿主同样让位,该事件本机无 OS 级通知(仅剩会话行高亮)。宿主在无桌面的服务器上时 spawn 会静默失败,无害;测试页"测试宿主通知"返回真实结果,可据此验证宿主机桌面环境是否可用。聚焦时宿主通知照弹(宿主无法感知窗口焦点,聚焦静默是浏览器侧偏好)。
91
+
83
92
  ## IM 推送
84
93
 
85
94
  安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot。触发逻辑与 webhook 一致,fire-and-forget 不重试;bot 离线时该次通知弃置不补发。
@@ -98,11 +107,11 @@ turn-notify:
98
107
 
99
108
  | 分区 | 内容 |
100
109
  |------|------|
101
- | 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
110
+ | 通知 | webhook 地址、最短回合时长、子代理过滤、宿主通知(总开关与回退)、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
102
111
  | 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
103
112
  | 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
104
113
  | IM | 绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
105
- | 测试 | 声音/页内/系统/webhook/IM 逐通道点火,回执即真实结果 |
114
+ | 测试 | 声音/页内/系统/宿主/webhook/IM 逐通道点火,回执即真实结果 |
106
115
 
107
116
  ## settings.yaml 参考
108
117
 
@@ -114,6 +123,8 @@ turn-notify:
114
123
  minTurnDurationMs: 5000 # 最短回合时长(毫秒),回合结束类通知短于此不送达
115
124
  rootsOnly: true # 子代理会话不通知
116
125
  suppressSubagentWake: true # 子代理相关回合不通知:后台委托未收尾或收尾唤醒(仅任务完成类)
126
+ hostNotify: false # 宿主机桌面通知总开关;浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时让位
127
+ hostNotifyFallback: false # 宿主通知补位:本机浏览器离场(2 秒内无在途长轮询)时弹宿主桌面通知,与总开关判定一致
117
128
  enabled: # 六类事件独立开关
118
129
  completed: true
119
130
  error: true
@@ -138,13 +149,14 @@ turn-notify:
138
149
 
139
150
  1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
140
151
  2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
141
- 3. **host 直发通道**:webhook 与 IM 由 host 进程直接投递(经路由名单过滤),不依赖浏览器;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
152
+ 3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤),不依赖浏览器;宿主通知按本机(回环来源)浏览器在场(在途长轮询)浏览器优先去重,离场补位;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
142
153
  4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
143
154
  5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
144
155
 
145
156
  ## 已知取舍
146
157
 
147
- - 标签页全关时,仅 webhook 与 IM 送达(声音与弹窗的浏览器前提)。
158
+ - 标签页全关时,仅 webhook、IM 与宿主桌面通知(如开启)送达(声音与弹窗的浏览器前提)。
159
+ - 宿主桌面通知为浏览器优先去重:本机浏览器在场(2 秒内有在途长轮询)时宿主让位,边界情况(认可窗口边界、网络抖动、反代致来源恒为回环而恒判在场)宁可不弹不误弹,优雅断连的离场盲区不超过 2 秒,异常断连受长轮询挂起上限约束(最坏约 25 秒);聚焦时宿主通知照弹(宿主无法感知窗口焦点);无桌面的宿主上 spawn 静默失败,无害。在场记账的形态校验(回环 + 续传 cursor + 同源)为启发式,防无意探活误压制与网页伪造在场(DNS rebinding 将域解析至回环的页面同源可过校验,但该页面同时已握有全部写配置 API,通知压制非最大损失面),不构成对抗本机进程的安全边界。
148
160
  - IM 依赖 dsh-im 的 bot 在线,离线时该次通知弃置不补发;成功回执仅代表平台受理。
149
161
  - 不同浏览器或不同地址打开的窗口各自发声,互不去重。
150
162
  - 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
- "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.9.0",
3
+ "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook、IM 与宿主桌面通知;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -35,9 +35,9 @@ window.__ModuleLoader__.load({
35
35
  // 显示回执等待上限:超时未回执按环境层拦截给出诊断
36
36
  const SYSTEM_SHOW_TIMEOUT_MS = 3 * 1000
37
37
 
38
- // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → bell);
39
- // 该插件未就绪时入队,由其启动时排空
40
- const NAV_ICON = { '消息通知': 'bell' }
38
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
39
+ // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
40
+ const NAV_ICON = { '消息通知': 'bell', 'dsh-turn-notify': 'bell' }
41
41
  if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
42
42
  else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
43
43
  else window.__navicIconQueue = [NAV_ICON]
@@ -277,7 +277,8 @@ window.__ModuleLoader__.load({
277
277
  // 放入 LOGIC 段由 parity 测试保证双实现不漂移
278
278
  const IDLE_AWAY_MS = 5 * 60 * 1000
279
279
 
280
- // routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源
280
+ // routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源。
281
+ // 宿主通知去重由浏览器优先模型承载:本机浏览器在场时宿主让位,本窗口照常弹系统通知
281
282
  function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
282
283
  const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
283
284
  const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
@@ -1016,6 +1017,8 @@ window.__ModuleLoader__.load({
1016
1017
  minTurnDurationMs: 5 * 1000,
1017
1018
  rootsOnly: true,
1018
1019
  suppressSubagentWake: true,
1020
+ hostNotify: false,
1021
+ hostNotifyFallback: false,
1019
1022
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
1020
1023
  imTargets: [],
1021
1024
  }
@@ -1368,7 +1371,7 @@ window.__ModuleLoader__.load({
1368
1371
  if (raw.length === 0) throw new Error('最短回合时长不能为空')
1369
1372
  const trimmedUrl = urlDraft.trim()
1370
1373
  // 所见即所存:输入框即配置值,清空并保存即禁用 webhook 通道
1371
- const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
1374
+ const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake, hostNotify: config.hostNotify, hostNotifyFallback: config.hostNotifyFallback }
1372
1375
  const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify(patchBody) })
1373
1376
  setConfig({ ...DEFAULT_CONFIG, ...res })
1374
1377
  if (res.soundMapping) setMappingState(res.soundMapping)
@@ -1465,6 +1468,16 @@ window.__ModuleLoader__.load({
1465
1468
  }, SYSTEM_TEST_DELAY_MS)
1466
1469
  }
1467
1470
 
1471
+ // 宿主桌面通知通道单独测试:等待 spawn 结束,真实结果随响应返回
1472
+ async function testHostNotify() {
1473
+ try {
1474
+ const result = await api('/api/turn-notify/test-host', { method: 'POST' })
1475
+ patch(result.ok ? '宿主通知已触发(' + result.detail + ')' : '宿主通知失败:' + result.detail, result.ok ? 'ok' : 'error')
1476
+ } catch (error) {
1477
+ patch('发送失败:' + (error && error.message ? error.message : String(error)), 'error')
1478
+ }
1479
+ }
1480
+
1468
1481
  async function testWebhook() {
1469
1482
  // 草稿与已保存值不同即存在未保存改动,测试的只能是已保存配置
1470
1483
  if (urlDraft.trim() !== String(config.webhookUrl || '').trim()) {
@@ -1611,7 +1624,7 @@ window.__ModuleLoader__.load({
1611
1624
  return h('div', { className: 'tn-panel' },
1612
1625
  h('div', { className: 'tn-head' },
1613
1626
  h('span', { className: 'tn-head__title' }, '消息通知'),
1614
- h('span', { className: 'tn-head__hint' }, '保存即生效;标签页全关时仅 webhook 与 IM 送达'),
1627
+ h('span', { className: 'tn-head__hint' }, '保存即生效;标签页全关时仅 webhook、IM 与宿主通知(如开启)送达'),
1615
1628
  ),
1616
1629
  h('div', { className: 'tn-tabs', role: 'tablist' },
1617
1630
  tabs.map((item) => h('button', {
@@ -1664,6 +1677,20 @@ window.__ModuleLoader__.load({
1664
1677
  }),
1665
1678
  ' 后台委托未收尾或收尾唤醒的回合不通知(仅完成类)'),
1666
1679
  ]),
1680
+ field('宿主通知', [
1681
+ h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast);浏览器优先:本机有浏览器窗口在线(2 秒内有在途长轮询)时由浏览器呈现,宿主不重复弹;本机无浏览器(标签页全关或仅远程浏览器)时宿主弹' },
1682
+ ...switchToggle({
1683
+ checked: config.hostNotify,
1684
+ onChange: (e) => setConfig({ ...config, hostNotify: e.target.checked }),
1685
+ }),
1686
+ ' 宿主机弹桌面通知'),
1687
+ h('label', { className: 'tn-meta tn-switch', title: '本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,离场时宿主补位桌面通知;与总开关判定一致,任一开启即生效' },
1688
+ ...switchToggle({
1689
+ checked: config.hostNotifyFallback,
1690
+ onChange: (e) => setConfig({ ...config, hostNotifyFallback: e.target.checked }),
1691
+ }),
1692
+ ' 仅当本机无浏览器接收时补位'),
1693
+ ], '由宿主进程直接弹 OS 桌面通知,服务器/浏览器全关场景可达;浏览器优先——本机浏览器在线时由浏览器呈现,宿主不重复弹,离场(2 秒内无在途长轮询)时宿主补位,边界情况(窗口边界与网络抖动)宁重复不漏。需点保存生效,测试页可逐条点火验证。'),
1667
1694
  field('事件分类', h('div', { className: 'tn-pills' },
1668
1695
  CATEGORIES.map((category) => h('span', {
1669
1696
  className: 'tn-pill' + (config.enabled[category] ? ' tn-pill--on' : ''),
@@ -1964,6 +1991,7 @@ window.__ModuleLoader__.load({
1964
1991
  }, '测试声音'),
1965
1992
  h('button', { className: 'tn-btn', title: '弹出一条页内卡片;页内提示音已开启时随卡片补一声(点火测试,不经分类静音约束)', onClick: testPageNotification }, '测试页内通知'),
1966
1993
  h('button', { className: 'tn-btn', title: '弹一条系统通知验证授权与送达;未授权会先引导授权', onClick: testSystemNotification }, '测试系统通知'),
1994
+ h('button', { className: 'tn-btn', title: '由宿主进程弹一条系统级桌面通知(osascript/notify-send/PowerShell toast),验证宿主通道可用性;弹在宿主所在机器', onClick: () => void testHostNotify() }, '测试宿主通知'),
1967
1995
  h('button', { className: 'tn-btn', title: '向已配置的 webhook 发送真实测试事件,回执显示投递结果;未配置时提示失败', onClick: () => void testWebhook() }, '测试 webhook'),
1968
1996
  config.imAvailable ? h('button', { className: 'tn-btn', disabled: busy, title: '向全部已配置目标发送真实测试事件,逐目标显示结果', onClick: () => void testIm() }, '测试 IM 通知') : null,
1969
1997
  ),
package/src/core.mjs CHANGED
@@ -283,8 +283,8 @@ export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOC
283
283
  export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
284
284
 
285
285
  // 呈现通道名单:事件→通道路由(kindRoutes)的合法取值;host 直发通道
286
- // (webhook/im)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
287
- export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im']
286
+ // (webhook/im/host)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
287
+ export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im', 'host']
288
288
 
289
289
  // 发声通道判定:页内提示、提示音与系统弹窗各自独立开关,聚焦静默仅压声音与系统弹窗;
290
290
  // 提示音另受分类配置约束:soundCategories 中该分类显式 false 即静音,缺省键与空分类放行。
@@ -496,6 +496,106 @@ export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
496
496
 
497
497
  const CONFIG_WEBHOOK_SCHEMES = ['http:', 'https:']
498
498
 
499
+ // 宿主桌面通知 spawn 超时:与 webhook 同语义,超时强杀不重试
500
+ export const HOST_NOTIFY_TIMEOUT_MS = 10 * 1000
501
+
502
+ // 回环判定:IPv6 环回、IPv4 环回与 IPv4 映射形态的 IPv4 环回;空与非法为假。
503
+ // 回环来源的浏览器窗口即与宿主同机,是本机去重的判定依据
504
+ export function isLoopbackAddress(address) {
505
+ if (typeof address !== 'string' || address.length === 0) return false
506
+ return address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
507
+ }
508
+
509
+ // 宿主通知决策:浏览器优先——本机浏览器在场由浏览器呈现,宿主让位(正常路径
510
+ // 零重复);离场由宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与
511
+ // 网络抖动),宁可重复不可漏。两开关均关不弹,任一开启即启用该通道。
512
+ export function hostNotifyWanted({ hostNotify, hostNotifyFallback, localBrowserPresent }) {
513
+ if (hostNotify !== true && hostNotifyFallback !== true) return false
514
+ return localBrowserPresent !== true
515
+ }
516
+
517
+ // 本机浏览器在场判定:有在途长轮询即在场信号(无论时间戳新旧);无在途时,
518
+ // 最近活动在认可窗口内(长轮询刚完成、断连刚出账)仍算在场,超窗或从未在场为假
519
+ export function isLocalBrowserPresent({ inFlightPolls, lastSeenAt, now, windowMs }) {
520
+ if (typeof inFlightPolls === 'number' && inFlightPolls > 0) return true
521
+ return typeof lastSeenAt === 'number' && lastSeenAt > 0 && now - lastSeenAt <= windowMs
522
+ }
523
+
524
+ // 本机浏览器轮询判定:回环来源 + 长轮询续传形态(带 cursor)+ fetch 请求形态
525
+ // + Origin 对账。投影为无守卫 GET,记账须排除可伪造在场的形态:no-cors(img
526
+ // 跨站探活)、无 cursor 的手工探测、跨源 fetch(同机恶意网页,sec-fetch-mode
527
+ // 同为 cors 无法区分,凭 Origin 与 Host 不符识别;同源 GET fetch 不带 Origin,
528
+ // 无 Origin 放行,旧浏览器同样覆盖)
529
+ export function isLocalBrowserPoll({ remoteAddress, hasCursor, secFetchMode, origin, host }) {
530
+ if (!isLoopbackAddress(remoteAddress)) return false
531
+ if (hasCursor !== true) return false
532
+ if (secFetchMode !== undefined && secFetchMode !== 'cors') return false
533
+ if (typeof origin === 'string' && origin.length > 0) {
534
+ try {
535
+ if (new URL(origin).host !== host) return false
536
+ } catch {
537
+ return false
538
+ }
539
+ }
540
+ return true
541
+ }
542
+
543
+ // 文本规整:换行压平为空格,防命令行参数与通知文案中的换行歧义
544
+ const flattenNotifyText = (text) => String(text).replace(/[\r\n]+/g, ' ')
545
+
546
+ // AppleScript 字符串字面量:反斜杠与双引号转义
547
+ const appleScriptQuote = (text) => '"' + flattenNotifyText(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
548
+
549
+ // PowerShell 单引号字符串字面量:单引号加倍
550
+ const powerShellQuote = (text) => "'" + flattenNotifyText(text).replace(/'/g, "''") + "'"
551
+
552
+ // Windows toast 的调用方标识:未打包进程借用系统 PowerShell 的 AppUserModelId,
553
+ // 缺此注册标识 toast 不展示
554
+ const WINDOWS_POWERSHELL_AUMID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
555
+
556
+ // 平台命令构造:零依赖 spawn 所需的可执行文件与参数;未知平台返回 null。
557
+ // darwin 走 AppleScript 通知,linux 走 libnotify,win32 走 WinRT toast 双行模板
558
+ export function hostNotifyCommand(platform, title, body) {
559
+ if (platform === 'darwin') {
560
+ return { file: 'osascript', args: ['-e', 'display notification ' + appleScriptQuote(body) + ' with title ' + appleScriptQuote(title)] }
561
+ }
562
+ if (platform === 'linux') {
563
+ // '--' 终结选项解析:正文(回答前缀)以 '-' 开头时防被 getopt 误作选项簇
564
+ return { file: 'notify-send', args: ['--', flattenNotifyText(title), flattenNotifyText(body)] }
565
+ }
566
+ if (platform === 'win32') {
567
+ const script = [
568
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
569
+ '$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
570
+ "$e = $t.GetElementsByTagName('text')",
571
+ '$e.Item(0).AppendChild($t.CreateTextNode(' + powerShellQuote(title) + ')) | Out-Null',
572
+ '$e.Item(1).AppendChild($t.CreateTextNode(' + powerShellQuote(body) + ')) | Out-Null',
573
+ '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(' + powerShellQuote(WINDOWS_POWERSHELL_AUMID) + ').Show([Windows.UI.Notifications.ToastNotification]::new($t))',
574
+ ].join('; ')
575
+ return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', script] }
576
+ }
577
+ return null
578
+ }
579
+
580
+ // 宿主桌面通知:spawn 平台命令并等待退出,真实结果返回;超时强杀,失败即弃
581
+ // 不重试(fire-and-forget,与 webhook 同语义)。execFileImpl 须为注入的
582
+ // node:child_process execFile,保持纯逻辑层可测。
583
+ export async function sendHostNotify({ title, body, platform, execFileImpl }) {
584
+ const command = hostNotifyCommand(platform, title, body)
585
+ if (command === null) return { ok: false, detail: '不支持的平台: ' + String(platform) }
586
+ try {
587
+ await new Promise((resolve, reject) => {
588
+ execFileImpl(command.file, command.args, { timeout: HOST_NOTIFY_TIMEOUT_MS, windowsHide: true }, (error) => {
589
+ if (error) reject(error)
590
+ else resolve()
591
+ })
592
+ })
593
+ return { ok: true, detail: '已触发宿主通知' }
594
+ } catch (error) {
595
+ return { ok: false, detail: error && error.message ? error.message : String(error) }
596
+ }
597
+ }
598
+
499
599
  // imTargets 读侧归一化:非数组回空,剔除形态非法项,仅保留两字段
500
600
  export function normalizeImTargets(raw) {
501
601
  if (!Array.isArray(raw)) return []
@@ -541,7 +641,7 @@ export function imBoundBotIds(list) {
541
641
  // 返回归一化后的补丁。
542
642
  export function validateConfigPatch(patch) {
543
643
  if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
544
- const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes']
644
+ const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback']
545
645
  for (const key of Object.keys(patch)) {
546
646
  if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
547
647
  }
@@ -594,6 +694,14 @@ export function validateConfigPatch(patch) {
594
694
  if (typeof patch.suppressSubagentWake !== 'boolean') return { ok: false, reason: 'suppressSubagentWake 须为布尔' }
595
695
  next.suppressSubagentWake = patch.suppressSubagentWake
596
696
  }
697
+ if ('hostNotify' in patch) {
698
+ if (typeof patch.hostNotify !== 'boolean') return { ok: false, reason: 'hostNotify 须为布尔' }
699
+ next.hostNotify = patch.hostNotify
700
+ }
701
+ if ('hostNotifyFallback' in patch) {
702
+ if (typeof patch.hostNotifyFallback !== 'boolean') return { ok: false, reason: 'hostNotifyFallback 须为布尔' }
703
+ next.hostNotifyFallback = patch.hostNotifyFallback
704
+ }
597
705
  if ('enabled' in patch) {
598
706
  const enabled = patch.enabled
599
707
  if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
@@ -657,6 +765,8 @@ export function resolvedConfig(settings) {
657
765
  minTurnDurationMs: duration,
658
766
  rootsOnly: source.rootsOnly !== false,
659
767
  suppressSubagentWake: source.suppressSubagentWake !== false,
768
+ hostNotify: source.hostNotify === true,
769
+ hostNotifyFallback: source.hostNotifyFallback === true,
660
770
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
661
771
  soundMapping,
662
772
  imTargets: normalizeImTargets(source.imTargets),
@@ -671,10 +781,12 @@ export function publicConfig(settings) {
671
781
  minTurnDurationMs: resolved.minTurnDurationMs,
672
782
  rootsOnly: resolved.rootsOnly,
673
783
  suppressSubagentWake: resolved.suppressSubagentWake,
784
+ hostNotify: resolved.hostNotify,
785
+ hostNotifyFallback: resolved.hostNotifyFallback,
674
786
  enabled: resolved.enabled,
675
787
  soundMapping: resolved.soundMapping,
676
788
  imTargets: resolved.imTargets,
677
789
  kindRoutes: resolved.kindRoutes,
678
790
  webhookUrl: resolved.webhookUrl,
679
791
  }
680
- }
792
+ }
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 命中分类即发 webhook、写入内存投影;webServer 路由供浏览器半区轮询投影与管理音效。
3
3
  // 音效持久化在 ~/.dsh/dsh-turn-notify/sounds/,投影不落盘。
4
4
 
5
+ import { execFile } from 'node:child_process'
5
6
  import { createHash } from 'node:crypto'
6
7
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
7
8
  import { homedir } from 'node:os'
@@ -25,6 +26,9 @@ import {
25
26
  isSubagent,
26
27
  isSubagentWakeTurn,
27
28
  isValidImBotId,
29
+ hostNotifyWanted,
30
+ isLocalBrowserPoll,
31
+ isLocalBrowserPresent,
28
32
  mapEventToCategory,
29
33
  mimeOf,
30
34
  normalizeImTargets,
@@ -32,6 +36,7 @@ import {
32
36
  readRawBody,
33
37
  resolvedConfig,
34
38
  sendWebhook,
39
+ sendHostNotify,
35
40
  shouldNotify,
36
41
  storedSessionTitle,
37
42
  unitRoutes,
@@ -49,11 +54,17 @@ export const name = 'dsh-turn-notify'
49
54
 
50
55
  export const inject = ['webServer']
51
56
 
57
+ // spawn 实现缝:测试注入观测 stub,生产恒为 child_process.execFile(client 半区 __test 同先例)
58
+ export const __hostSpawn = { impl: execFile }
59
+
52
60
  const NAMESPACE = 'turn-notify'
53
61
  const SOUNDS_DIR = join(homedir(), '.dsh', 'dsh-turn-notify', 'sounds')
54
62
  const REQUEST_BODY_MAX_BYTES = 64 * 1024
55
63
  // 长轮询挂起上限:低于客户端请求超时,保证客户端总在服务端放弃后才超时
56
64
  const LONG_POLL_WAIT_MS = 25 * 1000
65
+ // 本机浏览器在场认可窗口:在途长轮询出账后的宽限时长,浏览器优先去重的时间边界;
66
+ // 窗口内事件仍视为浏览器呈现,超窗即宿主补位(宁可重复不可漏)
67
+ export const CLIENT_PRESENCE_WINDOW_MS = 2 * 1000
57
68
 
58
69
  // dsh-im 投递错误码到 HTTP 状态的映射,未收录错误按网关失败处理
59
70
  const IM_ERROR_STATUS = { 'bad-request': 400, 'unknown-bot': 404, 'bot-not-connected': 503 }
@@ -81,10 +92,12 @@ const SETTINGS_SCHEMA = z.object({
81
92
  minTurnDurationMs: z.number().default(MIN_TURN_DURATION_MS).description('最短回合时长(毫秒),回合结束类通知短于此不送达;AI 提问与审批请求即时送达'),
82
93
  rootsOnly: z.boolean().default(true).description('子代理会话不通知'),
83
94
  suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
95
+ hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
96
+ hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:本机浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
84
97
  enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
85
98
  soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
86
99
  imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
87
- kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im),未配置的分类全通道放行'),
100
+ kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im/host),未配置的分类全通道放行'),
88
101
  })
89
102
 
90
103
  // 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
@@ -194,6 +207,10 @@ export function apply(ctx) {
194
207
  const titledSessions = new Set()
195
208
  // 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
196
209
  const assistantTails = new Map()
210
+ // 本机(回环来源)浏览器在场信号:在途长轮询计数与最近活动时刻,
211
+ // 在场 = 有在途请求或认可窗口内有活动,宿主通知的浏览器优先去重据此判定
212
+ let inFlightLocalPolls = 0
213
+ let lastLocalClientSeenAt = 0
197
214
  let seq = 0
198
215
  // 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
199
216
  let soundWriteQueue = Promise.resolve()
@@ -253,6 +270,25 @@ export function apply(ctx) {
253
270
  void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
254
271
  }
255
272
  if (routes === null || routes.indexOf('im') >= 0) deliverIm(unit, settings)
273
+ if (routes === null || routes.indexOf('host') >= 0) deliverHostNotify(unit, settings)
274
+ }
275
+
276
+ // 宿主桌面通知:浏览器优先,本机浏览器在场由浏览器呈现,宿主让位(正常路径
277
+ // 零重复);离场(无在途长轮询且超认可窗口)由宿主补位;fire-and-forget,
278
+ // spawn 失败不影响其余通道
279
+ function deliverHostNotify(unit, settings) {
280
+ const wanted = hostNotifyWanted({
281
+ hostNotify: settings.hostNotify,
282
+ hostNotifyFallback: settings.hostNotifyFallback,
283
+ localBrowserPresent: isLocalBrowserPresent({
284
+ inFlightPolls: inFlightLocalPolls,
285
+ lastSeenAt: lastLocalClientSeenAt,
286
+ now: Date.now(),
287
+ windowMs: CLIENT_PRESENCE_WINDOW_MS,
288
+ }),
289
+ })
290
+ if (!wanted) return
291
+ void sendHostNotify({ title: unit.text, body: unit.summary ?? '', platform: process.platform, execFileImpl: __hostSpawn.impl })
256
292
  }
257
293
 
258
294
  // 会话显示标题:优先 session-title 服务(与侧边栏行标题同一投影,客户端文本匹配高亮
@@ -404,13 +440,39 @@ export function apply(ctx) {
404
440
  path: '/api/turn-notify/projection',
405
441
  handler: route('GET', {}, async (req, res) => {
406
442
  const query = new URL(req.url, 'http://localhost').searchParams
407
- const rawCursor = Number(query.get('cursor'))
408
- const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
409
- if (query.has('cursor') && projection.version() === cursor) {
410
- await projection.wait(cursor, LONG_POLL_WAIT_MS)
443
+ // 回环来源的浏览器长轮询即本机在场信号,宿主通知的浏览器优先去重据此判定;
444
+ // 记账形态收紧防伪造:裸 GET(img 探活/监控探测)与跨源 fetch 不入账。
445
+ // 记账期间计入在途,断连(浏览器关闭/中止)经 res close 事件即时出账,
446
+ // 离场盲区收敛到认可窗口
447
+ let releaseLocalPoll = null
448
+ if (isLocalBrowserPoll({
449
+ remoteAddress: req.socket && req.socket.remoteAddress,
450
+ hasCursor: query.has('cursor'),
451
+ secFetchMode: req.headers ? req.headers['sec-fetch-mode'] : undefined,
452
+ origin: req.headers ? req.headers.origin : undefined,
453
+ host: req.headers ? req.headers.host : undefined,
454
+ })) {
455
+ inFlightLocalPolls += 1
456
+ let settled = false
457
+ releaseLocalPoll = () => {
458
+ if (settled) return
459
+ settled = true
460
+ inFlightLocalPolls -= 1
461
+ lastLocalClientSeenAt = Date.now()
462
+ }
463
+ res.once('close', releaseLocalPoll)
464
+ }
465
+ try {
466
+ const rawCursor = Number(query.get('cursor'))
467
+ const cursor = Number.isFinite(rawCursor) ? rawCursor : 0
468
+ if (query.has('cursor') && projection.version() === cursor) {
469
+ await projection.wait(cursor, LONG_POLL_WAIT_MS)
470
+ }
471
+ const settings = readSettings(ctx)
472
+ sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
473
+ } finally {
474
+ if (releaseLocalPoll !== null) releaseLocalPoll()
411
475
  }
412
- const settings = readSettings(ctx)
413
- sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
414
476
  }),
415
477
  })
416
478
  return () => projection.dispose()
@@ -631,6 +693,29 @@ export function apply(ctx) {
631
693
  }),
632
694
  }), 'turn-notify test-webhook route')
633
695
 
696
+ // 宿主通知测试点火互斥:spawn 代价高于既有端点(powershell/osascript 秒级),
697
+ // 单在途防连点堆积
698
+ let hostTestInFlight = false
699
+ ctx.effect(() =>
700
+ ctx.webServer.register({
701
+ kind: 'exact',
702
+ path: '/api/turn-notify/test-host',
703
+ // 等待 spawn 结束,真实结果随响应返回,测试按钮不谎报
704
+ handler: route('POST', { crossOrigin: true }, async (req, res) => {
705
+ if (hostTestInFlight) {
706
+ sendJson(res, 429, { error: '宿主通知测试进行中,请稍候' })
707
+ return
708
+ }
709
+ hostTestInFlight = true
710
+ try {
711
+ const unit = buildTestUnit()
712
+ sendJson(res, 200, await sendHostNotify({ title: unit.text, body: unit.summary ?? '', platform: process.platform, execFileImpl: __hostSpawn.impl }))
713
+ } finally {
714
+ hostTestInFlight = false
715
+ }
716
+ }),
717
+ }), 'turn-notify test-host route')
718
+
634
719
  ctx.effect(() =>
635
720
  ctx.webServer.register({
636
721
  kind: 'exact',
@@ -659,6 +659,8 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
659
659
  minTurnDurationMs: MIN_TURN_MS,
660
660
  rootsOnly: true,
661
661
  suppressSubagentWake: true,
662
+ hostNotify: false,
663
+ hostNotifyFallback: false,
662
664
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
663
665
  soundMapping: { completed: 'snd-1' },
664
666
  imTargets: [],
@@ -670,6 +672,8 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
670
672
  minTurnDurationMs: MIN_TURN_MS,
671
673
  rootsOnly: true,
672
674
  suppressSubagentWake: true,
675
+ hostNotify: false,
676
+ hostNotifyFallback: false,
673
677
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
674
678
  soundMapping: {},
675
679
  imTargets: [],
@@ -686,6 +690,8 @@ test('面板可见配置:webhookUrl 原文回显', () => {
686
690
  minTurnDurationMs: MIN_TURN_MS,
687
691
  rootsOnly: true,
688
692
  suppressSubagentWake: true,
693
+ hostNotify: false,
694
+ hostNotifyFallback: false,
689
695
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
690
696
  soundMapping: { completed: 'snd-1' },
691
697
  imTargets: [],
@@ -6,13 +6,14 @@ import { EventEmitter } from 'node:events'
6
6
 
7
7
  import { apply } from '../src/index.js'
8
8
 
9
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once
9
10
  function makeRes() {
10
- return {
11
- status: null,
12
- body: null,
13
- writeHead(status) { this.status = status },
14
- end(body) { this.body = JSON.parse(body) },
15
- }
11
+ const res = new EventEmitter()
12
+ res.status = null
13
+ res.body = null
14
+ res.writeHead = (status) => { res.status = status }
15
+ res.end = (body) => { res.body = JSON.parse(body) }
16
+ return res
16
17
  }
17
18
 
18
19
  function makeReq(method, payload, headers) {
@@ -0,0 +1,593 @@
1
+ // 宿主桌面通知 BDD:core 纯函数(回环判定 / 派发决策 / 平台命令 / spawn 执行 / 配置键)
2
+ // 与 host 接线集成(stub ctx 装载真实 src/index.js,stub spawn 观测派发)。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { EventEmitter } from 'node:events'
6
+
7
+ import { apply, __hostSpawn, CLIENT_PRESENCE_WINDOW_MS } from '../src/index.js'
8
+ import {
9
+ CHANNELS,
10
+ HOST_NOTIFY_TIMEOUT_MS,
11
+ hostNotifyCommand,
12
+ hostNotifyWanted,
13
+ isLocalBrowserPoll,
14
+ isLocalBrowserPresent,
15
+ isLoopbackAddress,
16
+ publicConfig,
17
+ resolvedConfig,
18
+ sendHostNotify,
19
+ validateConfigPatch,
20
+ } from '../src/core.mjs'
21
+
22
+ // ---- isLoopbackAddress:同机判定,回环地址即本机浏览器 ----
23
+
24
+ test('Given 回环形态地址 When 判定 Then 为真', () => {
25
+ assert.equal(isLoopbackAddress('::1'), true)
26
+ assert.equal(isLoopbackAddress('127.0.0.1'), true)
27
+ assert.equal(isLoopbackAddress('127.8.8.8'), true)
28
+ assert.equal(isLoopbackAddress('::ffff:127.0.0.1'), true)
29
+ })
30
+
31
+ test('Given 非回环或非法地址 When 判定 Then 为假', () => {
32
+ assert.equal(isLoopbackAddress('192.168.1.5'), false)
33
+ assert.equal(isLoopbackAddress('::ffff:192.168.1.5'), false)
34
+ assert.equal(isLoopbackAddress('::2'), false)
35
+ assert.equal(isLoopbackAddress(''), false)
36
+ assert.equal(isLoopbackAddress(undefined), false)
37
+ assert.equal(isLoopbackAddress(null), false)
38
+ })
39
+
40
+ // ---- isLocalBrowserPoll:在场记账形态收紧,防伪造在场压制宿主通知 ----
41
+
42
+ test('Given 回环加 cursor 加 fetch 形态或无 Sec-Fetch 头 When 判定 Then 为真', () => {
43
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'cors' }), true)
44
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '::1', hasCursor: true, secFetchMode: undefined }), true)
45
+ })
46
+
47
+ test('Given 非回环或无 cursor 或 no-cors 形态 When 判定 Then 为假', () => {
48
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '192.168.1.5', hasCursor: true, secFetchMode: 'cors' }), false)
49
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: false, secFetchMode: 'cors' }), false)
50
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'no-cors' }), false)
51
+ assert.equal(isLocalBrowserPoll({ remoteAddress: undefined, hasCursor: true, secFetchMode: 'cors' }), false)
52
+ })
53
+
54
+ test('Given Origin 缺席或与 Host 一致 When 判定 Then 为真;跨源或畸形 Origin Then 为假', () => {
55
+ const base = { remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'cors' }
56
+ assert.equal(isLocalBrowserPoll({ ...base, origin: undefined, host: '127.0.0.1:3080' }), true, '同源 GET fetch 不带 Origin')
57
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'http://127.0.0.1:3080', host: '127.0.0.1:3080' }), true)
58
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'https://evil.example', host: '127.0.0.1:3080' }), false, '跨源 fetch 必带且与 Host 不符')
59
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'not a url', host: '127.0.0.1:3080' }), false, '畸形 Origin 不放行')
60
+ })
61
+
62
+ // ---- hostNotifyWanted:浏览器优先,在场让位、离场补位;重复只发生在边界情况 ----
63
+
64
+ const NOW = 1000 * 1000
65
+ const WINDOW_MS = 2 * 1000
66
+
67
+ test('Given 任一开关开且本机浏览器不在场 When 决策 Then 弹', () => {
68
+ assert.equal(hostNotifyWanted({ hostNotify: true, localBrowserPresent: false }), true)
69
+ assert.equal(hostNotifyWanted({ hostNotifyFallback: true, localBrowserPresent: false }), true)
70
+ assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, localBrowserPresent: false }), true)
71
+ })
72
+
73
+ test('Given 任一开关开且本机浏览器在场 When 决策 Then 让位不弹(浏览器优先,正常路径零重复)', () => {
74
+ assert.equal(hostNotifyWanted({ hostNotify: true, localBrowserPresent: true }), false)
75
+ assert.equal(hostNotifyWanted({ hostNotifyFallback: true, localBrowserPresent: true }), false)
76
+ assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, localBrowserPresent: true }), false)
77
+ })
78
+
79
+ test('Given 两开关均关 When 决策 Then 恒不弹', () => {
80
+ assert.equal(hostNotifyWanted({ localBrowserPresent: false }), false)
81
+ assert.equal(hostNotifyWanted({ hostNotify: false, hostNotifyFallback: false, localBrowserPresent: false }), false)
82
+ })
83
+
84
+ // ---- isLocalBrowserPresent:在场 = 有在途长轮询,或宽限窗口内有活动 ----
85
+
86
+ test('Given 在途长轮询存在 When 判定在场 Then 为真(时间戳过期不影响)', () => {
87
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 1, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), true)
88
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 3, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), true)
89
+ })
90
+
91
+ test('Given 无在途 When 判定在场 Then 最近活动在窗口内为真,超窗或从未为假', () => {
92
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - 1, now: NOW, windowMs: WINDOW_MS }), true)
93
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS, now: NOW, windowMs: WINDOW_MS }), true)
94
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), false)
95
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), false)
96
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: undefined, now: NOW, windowMs: WINDOW_MS }), false)
97
+ })
98
+
99
+ // ---- hostNotifyCommand:平台命令构造与文本转义 ----
100
+
101
+ test('Given darwin When 构造 Then osascript 单命令携带转义后的标题与正文', () => {
102
+ const command = hostNotifyCommand('darwin', '标题', '正文')
103
+ assert.equal(command.file, 'osascript')
104
+ assert.deepEqual(command.args.slice(0, 1), ['-e'])
105
+ const script = command.args[1]
106
+ assert.ok(script.includes('display notification'), '应含 display notification')
107
+ assert.ok(script.includes('with title'), '应含 with title')
108
+ assert.ok(script.includes('标题'), '应含标题')
109
+ assert.ok(script.includes('正文'), '应含正文')
110
+ })
111
+
112
+ test('Given darwin 且文本含双引号反斜杠换行 When 构造 Then 转义并压平', () => {
113
+ const script = hostNotifyCommand('darwin', 'a"b\\c\nd', 'x').args[1]
114
+ assert.ok(script.includes('a\\"b\\\\c d'), '双引号反斜杠须转义且换行压平: ' + script)
115
+ assert.ok(!script.includes('\n'), '不得残留换行')
116
+ })
117
+
118
+ test('Given linux When 构造 Then notify-send 选项终结符后标题正文直传', () => {
119
+ const command = hostNotifyCommand('linux', 'T', 'B')
120
+ assert.equal(command.file, 'notify-send')
121
+ assert.deepEqual(command.args, ['--', 'T', 'B'])
122
+ })
123
+
124
+ test('Given linux 且正文以减号开头 When 构造 Then 经选项终结符原样直传', () => {
125
+ const command = hostNotifyCommand('linux', 'T', '- 修改了配置')
126
+ assert.deepEqual(command.args, ['--', 'T', '- 修改了配置'])
127
+ })
128
+
129
+ test('Given linux 且文本含换行 When 构造 Then 换行压平为空格', () => {
130
+ const command = hostNotifyCommand('linux', 'a\nb', 'c\nd')
131
+ assert.deepEqual(command.args, ['--', 'a b', 'c d'])
132
+ })
133
+
134
+ test('Given win32 When 构造 Then powershell 无配置非交互单命令脚本', () => {
135
+ const command = hostNotifyCommand('win32', 'T', 'B')
136
+ assert.equal(command.file, 'powershell.exe')
137
+ assert.deepEqual(command.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command'])
138
+ const script = command.args[3]
139
+ assert.ok(script.includes('ToastNotificationManager'), '应使用 WinRT toast')
140
+ assert.ok(script.includes('ToastText02'), '应使用双行模板')
141
+ assert.ok(script.includes('CreateTextNode'), '文本应经 CreateTextNode 注入')
142
+ })
143
+
144
+ test('Given win32 且文本含单引号 When 构造 Then 单引号加倍且换行压平', () => {
145
+ const script = hostNotifyCommand('win32', "it's\nok", 'B').args[3]
146
+ assert.ok(script.includes("it''s ok"), '单引号须加倍且换行压平: ' + script)
147
+ assert.ok(!script.includes('\n'), '不得残留换行')
148
+ })
149
+
150
+ test('Given 未知平台 When 构造 Then 返回 null', () => {
151
+ assert.equal(hostNotifyCommand('freebsd', 'T', 'B'), null)
152
+ })
153
+
154
+ // ---- sendHostNotify:真实结果返回,依赖注入 ----
155
+
156
+ test('Given spawn 成功 When 发送 Then 返回 ok 且传参含超时与隐藏窗口', async () => {
157
+ const calls = []
158
+ const result = await sendHostNotify({
159
+ title: 'T', body: 'B', platform: 'linux',
160
+ execFileImpl: (file, args, options, callback) => {
161
+ calls.push({ file, args, options })
162
+ callback(null)
163
+ },
164
+ })
165
+ assert.deepEqual(result, { ok: true, detail: '已触发宿主通知' })
166
+ assert.equal(calls.length, 1)
167
+ assert.equal(calls[0].file, 'notify-send')
168
+ assert.equal(calls[0].options.timeout, HOST_NOTIFY_TIMEOUT_MS)
169
+ assert.equal(calls[0].options.windowsHide, true)
170
+ })
171
+
172
+ test('Given spawn 失败 When 发送 Then 返回失败与原因', async () => {
173
+ const result = await sendHostNotify({
174
+ title: 'T', body: 'B', platform: 'linux',
175
+ execFileImpl: (file, args, options, callback) => callback(new Error('boom')),
176
+ })
177
+ assert.equal(result.ok, false)
178
+ assert.equal(result.detail, 'boom')
179
+ })
180
+
181
+ test('Given 不支持平台 When 发送 Then 不触碰 spawn 返回不支持', async () => {
182
+ let touched = false
183
+ const result = await sendHostNotify({
184
+ title: 'T', body: 'B', platform: 'freebsd',
185
+ execFileImpl: () => { touched = true },
186
+ })
187
+ assert.equal(touched, false)
188
+ assert.equal(result.ok, false)
189
+ assert.ok(result.detail.includes('freebsd'))
190
+ })
191
+
192
+ // ---- 配置键:校验与解析 ----
193
+
194
+ test('Given 补丁含两键布尔 When 校验 Then 通过并归一;非布尔 Then 拒绝', () => {
195
+ const good = validateConfigPatch({ hostNotify: true, hostNotifyFallback: false })
196
+ assert.equal(good.ok, true)
197
+ assert.deepEqual(good.patch, { hostNotify: true, hostNotifyFallback: false })
198
+ assert.equal(validateConfigPatch({ hostNotify: 'yes' }).ok, false)
199
+ assert.equal(validateConfigPatch({ hostNotifyFallback: 1 }).ok, false)
200
+ })
201
+
202
+ test('Given 缺省配置 When 解析 Then 两键回 false;CHANNELS 含 host', () => {
203
+ const resolved = resolvedConfig({})
204
+ assert.equal(resolved.hostNotify, false)
205
+ assert.equal(resolved.hostNotifyFallback, false)
206
+ const config = publicConfig({ hostNotify: true })
207
+ assert.equal(config.hostNotify, true)
208
+ assert.equal(config.hostNotifyFallback, false)
209
+ assert.ok(CHANNELS.indexOf('host') >= 0, 'host 应为可路由通道')
210
+ })
211
+
212
+ // ---- host 接线集成:stub spawn 观测派发,presence 经投影路由的来源地址注入 ----
213
+
214
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once/emit;
215
+ // end 不自动触发 close,断连时机由测试显式 emit 模拟
216
+ function makeRes() {
217
+ const res = new EventEmitter()
218
+ res.status = null
219
+ res.body = null
220
+ res.writeHead = (status) => { res.status = status }
221
+ res.end = (body) => { res.body = JSON.parse(body) }
222
+ return res
223
+ }
224
+
225
+ function makeReq(method, payload, headers) {
226
+ const req = new EventEmitter()
227
+ req.method = method
228
+ req.url = '/api/turn-notify/config'
229
+ req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
230
+ const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
231
+ process.nextTick(() => {
232
+ if (data !== null) req.emit('data', data)
233
+ req.readableEnded = true
234
+ req.emit('end')
235
+ })
236
+ return req
237
+ }
238
+
239
+ const JSON_HEADERS = { 'content-type': 'application/json' }
240
+
241
+ function makeCtx() {
242
+ const routes = new Map()
243
+ const handlers = new Map()
244
+ const doc = new Map()
245
+ const settingsService = {
246
+ register(ns) {
247
+ if (!doc.has(ns)) doc.set(ns, {})
248
+ return { get: () => doc.get(ns), watch: () => () => {} }
249
+ },
250
+ get: (ns) => doc.get(ns),
251
+ update: async (ns, patch) => { doc.set(ns, { ...(doc.get(ns) ?? {}), ...patch }) },
252
+ }
253
+ const ctx = {
254
+ on(event, fn) { handlers.set(event, fn) },
255
+ get(key) { return key === 'settings' ? settingsService : undefined },
256
+ inject(deps, fn) { fn({ settings: settingsService }) },
257
+ effect(thunk) { thunk() },
258
+ webServer: { register(route) { routes.set(route.path, route.handler) } },
259
+ }
260
+ return { ctx, routes, handlers }
261
+ }
262
+
263
+ const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve))
264
+
265
+ const MAIN = { id: 'M', header: { delegationDepth: 0 } }
266
+ const turnEnd = (kind) => ({ type: 'turn/end', data: { reason: { kind } } })
267
+
268
+ async function postConfig(routes, patch) {
269
+ const res = makeRes()
270
+ await routes.get('/api/turn-notify/config')(makeReq('POST', patch, JSON_HEADERS), res)
271
+ assert.equal(res.status, 200)
272
+ return res
273
+ }
274
+
275
+ async function pollProjection(routes, remoteAddress) {
276
+ const req = makeReq('GET')
277
+ req.url = '/api/turn-notify/projection'
278
+ if (remoteAddress !== undefined) req.socket = { remoteAddress }
279
+ const res = makeRes()
280
+ await routes.get('/api/turn-notify/projection')(req, res)
281
+ assert.equal(res.status, 200)
282
+ return res
283
+ }
284
+
285
+ // 本机浏览器长轮询记账形态:回环 + 带续传 cursor + fetch 形态头;
286
+ // cursor 取超前值立即返回,不挂起
287
+ const FUTURE_CURSOR = 2 ** 40
288
+ async function browserPoll(routes, remoteAddress) {
289
+ const req = makeReq('GET', undefined, { 'sec-fetch-mode': 'cors' })
290
+ req.url = '/api/turn-notify/projection?cursor=' + FUTURE_CURSOR
291
+ req.socket = { remoteAddress }
292
+ const res = makeRes()
293
+ await routes.get('/api/turn-notify/projection')(req, res)
294
+ assert.equal(res.status, 200)
295
+ return res
296
+ }
297
+
298
+ // 伪形态轮询:回环来源但缺 cursor / no-cors / 跨源 Origin,记账须拒绝
299
+ async function rawShapePoll(routes, remoteAddress, secFetchMode, withCursor, origin) {
300
+ const headers = secFetchMode === undefined ? {} : { 'sec-fetch-mode': secFetchMode }
301
+ if (origin !== undefined) headers.origin = origin
302
+ const req = makeReq('GET', undefined, headers)
303
+ req.url = '/api/turn-notify/projection' + (withCursor ? '?cursor=' + FUTURE_CURSOR : '')
304
+ req.socket = { remoteAddress }
305
+ const res = makeRes()
306
+ await routes.get('/api/turn-notify/projection')(req, res)
307
+ assert.equal(res.status, 200)
308
+ return res
309
+ }
310
+
311
+ // spawn 观测缝:替换实现收集调用,测试结束还原
312
+ async function withStubSpawn(run) {
313
+ const spawns = []
314
+ const original = __hostSpawn.impl
315
+ __hostSpawn.impl = (file, args, options, callback) => {
316
+ spawns.push({ file, args })
317
+ callback(null)
318
+ }
319
+ try {
320
+ return await run(spawns)
321
+ } finally {
322
+ __hostSpawn.impl = original
323
+ }
324
+ }
325
+
326
+ async function emitCompletedTurn(handlers) {
327
+ handlers.get('session/event')(MAIN, { type: 'turn/start' })
328
+ handlers.get('session/event')(MAIN, turnEnd('completed'))
329
+ await flushMicrotasks()
330
+ }
331
+
332
+ // 挂起中的长轮询:cursor 等于当前版本(配置 POST 后为 1),服务端挂起等待,
333
+ // 模拟真实客户端的在途请求;返回 pending 承诺与 res 桩(供断连模拟与唤醒等待)
334
+ function hangingPoll(routes, remoteAddress) {
335
+ const req = makeReq('GET', undefined, { 'sec-fetch-mode': 'cors' })
336
+ req.url = '/api/turn-notify/projection?cursor=1'
337
+ req.socket = { remoteAddress }
338
+ const res = makeRes()
339
+ const pending = routes.get('/api/turn-notify/projection')(req, res)
340
+ return { pending, res }
341
+ }
342
+
343
+ test('Given 总开关开且本机长轮询在途 When 回合完成 Then 宿主让位;离场超窗后接管', async () => {
344
+ await withStubSpawn(async (spawns) => {
345
+ const { ctx, routes, handlers } = makeCtx()
346
+ apply(ctx)
347
+ const saved = await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
348
+ assert.equal(saved.body.hostNotify, true)
349
+ const { pending, res } = hangingPoll(routes, '127.0.0.1')
350
+ await flushMicrotasks()
351
+ // 在途请求即在场信号:浏览器优先,宿主让位,正常路径零重复
352
+ await emitCompletedTurn(handlers)
353
+ assert.equal(spawns.length, 0, '本机长轮询在途,宿主让位')
354
+ // 在途请求被回合事件唤醒完成,宽限窗口内仍算在场
355
+ await pending
356
+ await emitCompletedTurn(handlers)
357
+ assert.equal(spawns.length, 0, '长轮询刚完成,宽限窗口内仍由浏览器呈现')
358
+ // 推进时钟越过宽限窗口:本机视为离场,宿主接管(宁可重复不可漏)
359
+ const realNow = Date.now
360
+ Date.now = () => realNow() + CLIENT_PRESENCE_WINDOW_MS + 1
361
+ try {
362
+ await emitCompletedTurn(handlers)
363
+ } finally { Date.now = realNow }
364
+ assert.equal(spawns.length, 1, '离场超窗,宿主接管')
365
+ const projection = await pollProjection(routes, undefined)
366
+ assert.equal(projection.body.units.length, 3, '浏览器呈现通道不受让位影响')
367
+ })
368
+ })
369
+
370
+ test('Given 总开关开且本机长轮询断连 When 回合完成 Then 认可窗口内让位,超窗接管', async () => {
371
+ await withStubSpawn(async (spawns) => {
372
+ const { ctx, routes, handlers } = makeCtx()
373
+ apply(ctx)
374
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
375
+ const { pending, res } = hangingPoll(routes, '127.0.0.1')
376
+ await flushMicrotasks()
377
+ // 浏览器关闭中止请求:close 事件即时出账,最近活动定格于断连时刻
378
+ res.emit('close')
379
+ await emitCompletedTurn(handlers)
380
+ assert.equal(spawns.length, 0, '断连后认可窗口内仍算在场(边界情况宁可不弹)')
381
+ // 推进时钟越过认可窗口:宿主接管
382
+ const realNow = Date.now
383
+ Date.now = () => realNow() + CLIENT_PRESENCE_WINDOW_MS + 1
384
+ try {
385
+ await emitCompletedTurn(handlers)
386
+ } finally { Date.now = realNow }
387
+ assert.equal(spawns.length, 1, '断连超窗,宿主接管')
388
+ // 唤醒挂起的等待事务,防测试尾部悬挂 25 秒定时器
389
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
390
+ await pending
391
+ })
392
+ })
393
+
394
+ test('Given 记账轮询正常完成后再触发 close When 时钟推进越过认可窗 Then 出账幂等不双计', async () => {
395
+ await withStubSpawn(async (spawns) => {
396
+ const { ctx, routes, handlers } = makeCtx()
397
+ apply(ctx)
398
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
399
+ // 全程虚拟时钟:消解真实间隔,配平断言只依赖显式推进
400
+ const realNow = Date.now
401
+ let fake = 1000 * 1000
402
+ Date.now = () => fake
403
+ try {
404
+ const first = await browserPoll(routes, '127.0.0.1')
405
+ // 生产正常完成路径:finally 已出账,响应完成后的 close 再触发须被幂等消化;
406
+ // 若双出账致计数走负,时钟推进后本应让位的在途轮询将误弹
407
+ first.emit('close')
408
+ const second = hangingPoll(routes, '127.0.0.1')
409
+ await flushMicrotasks()
410
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
411
+ await emitCompletedTurn(handlers)
412
+ assert.equal(spawns.length, 0, '计数未被双出账破坏,在途轮询仍正常让位')
413
+ await second.pending
414
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
415
+ await emitCompletedTurn(handlers)
416
+ assert.equal(spawns.length, 1, '出账配平正确,超窗接管不受影响')
417
+ } finally { Date.now = realNow }
418
+ })
419
+ })
420
+
421
+ test('Given 双 tab 并发挂起其一断连 When 时钟推进越过认可窗 Then 在途方仍让位,全部出账后接管', async () => {
422
+ await withStubSpawn(async (spawns) => {
423
+ const { ctx, routes, handlers } = makeCtx()
424
+ apply(ctx)
425
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
426
+ const realNow = Date.now
427
+ let fake = 1000 * 1000
428
+ Date.now = () => fake
429
+ try {
430
+ const tabA = hangingPoll(routes, '127.0.0.1')
431
+ const tabB = hangingPoll(routes, '::1')
432
+ await flushMicrotasks()
433
+ // tabA 断连即时出账,tabB 独力承载在途信号
434
+ tabA.res.emit('close')
435
+ await flushMicrotasks()
436
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
437
+ await emitCompletedTurn(handlers)
438
+ assert.equal(spawns.length, 0, 'tabA 断连出账,tabB 在途,整体在场')
439
+ // 回合事件唤醒两个挂起轮询,出账后计数归零
440
+ await Promise.all([tabA.pending, tabB.pending])
441
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
442
+ await emitCompletedTurn(handlers)
443
+ assert.equal(spawns.length, 1, '全部出账,计数归零,超窗接管')
444
+ } finally { Date.now = realNow }
445
+ })
446
+ })
447
+
448
+ test('Given 总开关开且仅远程浏览器在线 When 回合完成 Then 宿主弹', async () => {
449
+ await withStubSpawn(async (spawns) => {
450
+ const { ctx, routes, handlers } = makeCtx()
451
+ apply(ctx)
452
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
453
+ await browserPoll(routes, '192.168.1.5')
454
+ await emitCompletedTurn(handlers)
455
+ assert.equal(spawns.length, 1, '远程浏览器不与宿主同机,照常弹')
456
+ assert.ok(spawns[0].args.flat().join(' ').includes('任务完成'), '通知文本应随 spawn 传出')
457
+ })
458
+ })
459
+
460
+ test('Given 总开关开且无浏览器在场 When 回合完成 Then 宿主弹', async () => {
461
+ await withStubSpawn(async (spawns) => {
462
+ const { ctx, routes, handlers } = makeCtx()
463
+ apply(ctx)
464
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
465
+ await emitCompletedTurn(handlers)
466
+ assert.equal(spawns.length, 1)
467
+ })
468
+ })
469
+
470
+ test('Given kindRoutes 名单不含 host When 总开关开 Then 不弹', async () => {
471
+ await withStubSpawn(async (spawns) => {
472
+ const { ctx, routes, handlers } = makeCtx()
473
+ apply(ctx)
474
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0, kindRoutes: { completed: ['webhook'] } })
475
+ await emitCompletedTurn(handlers)
476
+ assert.equal(spawns.length, 0, '路由名单外的宿主通道不送达')
477
+ })
478
+ })
479
+
480
+ test('Given 回退开关开 When 本机长轮询在途 Then 不弹;仅远程在线 Then 弹', async () => {
481
+ await withStubSpawn(async (spawns) => {
482
+ const local = makeCtx()
483
+ apply(local.ctx)
484
+ await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
485
+ const localPoll = hangingPoll(local.routes, '::1')
486
+ await flushMicrotasks()
487
+ await emitCompletedTurn(local.handlers)
488
+ assert.equal(spawns.length, 0, '本机浏览器在途,回退不触发')
489
+ await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
490
+ await localPoll.pending
491
+ // 独立实例:本机从未有浏览器,仅远程浏览器轮询过
492
+ const remote = makeCtx()
493
+ apply(remote.ctx)
494
+ await postConfig(remote.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
495
+ await browserPoll(remote.routes, '10.0.0.8')
496
+ await emitCompletedTurn(remote.handlers)
497
+ assert.equal(spawns.length, 1, '本机无浏览器,回退触发')
498
+ })
499
+ })
500
+
501
+ test('Given 裸 GET 形态(无 cursor 或 no-cors 或跨源)When 回合完成 Then 回退不触发(伪造在场不入账)', async () => {
502
+ await withStubSpawn(async (spawns) => {
503
+ const { ctx, routes, handlers } = makeCtx()
504
+ apply(ctx)
505
+ await postConfig(routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
506
+ // 回环来源但缺 cursor:手工探测形态
507
+ await rawShapePoll(routes, '127.0.0.1', 'cors', false)
508
+ // 回环来源带 cursor 但 no-cors:同机网页 img 探活形态
509
+ await rawShapePoll(routes, '127.0.0.1', 'no-cors', true)
510
+ // 回环来源带 cursor 且 cors 但跨源 Origin:同机恶意网页标准 fetch 形态
511
+ await rawShapePoll(routes, '127.0.0.1', 'cors', true, 'https://evil.example')
512
+ await emitCompletedTurn(handlers)
513
+ assert.equal(spawns.length, 1, '伪造在场不入账,本机无真实浏览器时回退照常触发')
514
+ })
515
+ })
516
+
517
+ test('Given spawn 失败 When 回合完成 Then 无未处理拒绝且投影照常', async () => {
518
+ const rejections = []
519
+ const onUnhandled = (reason) => rejections.push(reason)
520
+ process.on('unhandledRejection', onUnhandled)
521
+ const original = __hostSpawn.impl
522
+ __hostSpawn.impl = (file, args, options, callback) => callback(new Error('no desktop'))
523
+ try {
524
+ const { ctx, routes, handlers } = makeCtx()
525
+ apply(ctx)
526
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
527
+ handlers.get('session/event')(MAIN, { type: 'turn/start' })
528
+ handlers.get('session/event')(MAIN, turnEnd('completed'))
529
+ await flushMicrotasks()
530
+ await flushMicrotasks()
531
+ assert.equal(rejections.length, 0)
532
+ const projection = await pollProjection(routes, undefined)
533
+ assert.equal(projection.body.units.length, 1)
534
+ } finally {
535
+ process.off('unhandledRejection', onUnhandled)
536
+ __hostSpawn.impl = original
537
+ }
538
+ })
539
+
540
+ test('Given 两开关均关 When 回合完成 Then 不弹', async () => {
541
+ await withStubSpawn(async (spawns) => {
542
+ const { ctx, routes, handlers } = makeCtx()
543
+ apply(ctx)
544
+ await postConfig(routes, { minTurnDurationMs: 0 })
545
+ await emitCompletedTurn(handlers)
546
+ assert.equal(spawns.length, 0)
547
+ })
548
+ })
549
+
550
+ test('Given test-host 路由 When 合法点火 Then 返回真实结果;非法方法与跨源被拒', async () => {
551
+ await withStubSpawn(async (spawns) => {
552
+ const { ctx, routes } = makeCtx()
553
+ apply(ctx)
554
+ const ok = makeRes()
555
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST'), ok)
556
+ assert.equal(ok.status, 200)
557
+ assert.deepEqual(ok.body, { ok: true, detail: '已触发宿主通知' })
558
+ assert.equal(spawns.length, 1)
559
+ assert.ok(spawns[0].args.flat().join(' ').includes('[dsh]'), '测试事件文本应随 spawn 传出')
560
+ const method = makeRes()
561
+ await routes.get('/api/turn-notify/test-host')(makeReq('PUT'), method)
562
+ assert.equal(method.status, 405)
563
+ const cross = makeRes()
564
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST', undefined, { origin: 'https://evil.example' }), cross)
565
+ assert.equal(cross.status, 403)
566
+ assert.equal(spawns.length, 1, '被拒请求不触发 spawn')
567
+ })
568
+ })
569
+
570
+ test('Given 测试点火在途 When 再次点火 Then 429 且不重复 spawn', async () => {
571
+ const releases = []
572
+ const original = __hostSpawn.impl
573
+ __hostSpawn.impl = (file, args, options, callback) => { releases.push(callback) }
574
+ try {
575
+ const { ctx, routes } = makeCtx()
576
+ apply(ctx)
577
+ const first = routes.get('/api/turn-notify/test-host')(makeReq('POST'), makeRes())
578
+ const secondRes = makeRes()
579
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST'), secondRes)
580
+ assert.equal(secondRes.status, 429, '在途期间拒绝并发点火')
581
+ assert.equal(releases.length, 1, '在途期间不重复 spawn')
582
+ releases[0](null)
583
+ await first
584
+ // 释放后互斥复位:再次点火可正常 spawn;spawn 回执由本测试持有,先到账再释放
585
+ const third = makeRes()
586
+ const thirdCall = routes.get('/api/turn-notify/test-host')(makeReq('POST'), third)
587
+ await flushMicrotasks()
588
+ assert.equal(releases.length, 2, '互斥复位后恢复 spawn')
589
+ releases[1](null)
590
+ await thirdCall
591
+ assert.equal(third.status, 200)
592
+ } finally { __hostSpawn.impl = original }
593
+ })
@@ -20,19 +20,20 @@ const soundsDir = join(homeRoot, '.dsh', 'dsh-turn-notify', 'sounds')
20
20
  const JSON_HEADERS = { 'content-type': 'application/json' }
21
21
  const WAV_BYTES = Buffer.from([1, 2, 3, 4])
22
22
 
23
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once
23
24
  function makeRes() {
24
- return {
25
- status: null,
26
- body: null,
27
- raw: null,
28
- headers: null,
29
- writeHead(status, headers) { this.status = status; this.headers = headers },
30
- end(body) {
31
- if (Buffer.isBuffer(body)) this.raw = body
32
- else if (typeof body === 'string' && (this.headers ? String(this.headers['content-type']) : '').includes('json')) this.body = JSON.parse(body)
33
- else this.raw = body
34
- },
25
+ const res = new EventEmitter()
26
+ res.status = null
27
+ res.body = null
28
+ res.raw = null
29
+ res.headers = null
30
+ res.writeHead = (status, headers) => { res.status = status; res.headers = headers }
31
+ res.end = (body) => {
32
+ if (Buffer.isBuffer(body)) res.raw = body
33
+ else if (typeof body === 'string' && (res.headers ? String(res.headers['content-type']) : '').includes('json')) res.body = JSON.parse(body)
34
+ else res.raw = body
35
35
  }
36
+ return res
36
37
  }
37
38
 
38
39
  function makeReq(method, payload, headers, url) {