@mzzsfy/dsh-turn-notify 0.11.0 → 0.12.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 +14 -10
- package/package.json +1 -1
- package/src/client.js +26 -5
- package/src/core.mjs +21 -20
- package/src/index.js +25 -22
- package/test/core.test.mjs +10 -0
- package/test/event-wiring.test.mjs +21 -0
- package/test/host-notify.test.mjs +96 -113
package/README.md
CHANGED
|
@@ -82,16 +82,16 @@ turn-notify:
|
|
|
82
82
|
|
|
83
83
|
## 宿主桌面通知
|
|
84
84
|
|
|
85
|
-
开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10
|
|
85
|
+
开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10 秒,失败即弃不重试,不影响其余通道。去重方向为**浏览器优先**:浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,宿主让位,正常路径零重复;浏览器离场(标签页全关)时宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与网络抖动),宁可重复不可漏。两个开关判定一致,任一开启即生效:
|
|
86
86
|
|
|
87
87
|
- **宿主机弹桌面通知**:启用宿主通知通道(浏览器优先去重内建)。
|
|
88
|
-
-
|
|
88
|
+
- **仅当无浏览器接收时补位**:与总开关等效,作为显式的补位语义表达保留。
|
|
89
89
|
|
|
90
|
-
"
|
|
90
|
+
"浏览器在场"按投影长轮询记账:任意来源 + 续传 cursor + 同源形态的 GET 在服务端挂起期间计为在途,断连(标签页关闭/请求中止)经连接关闭事件即时出账,最近活动在 2 秒认可窗口内仍算在场——优雅断连的盲区不超过 2 秒;异常断连(睡眠、强杀、断网)服务端无感知,受长轮询挂起上限约束,最坏约 25 秒后自愈出账。来源不限地址:浏览器可在任意机器访问 dsh(局域网部署),在场信号看的是"有无 dsh 页面在轮询",与浏览器同不同机无关。浏览器在场但在场浏览器的呈现偏好全关(声音/弹窗/页内全关或系统通知未授权)时,宿主同样让位,该事件在浏览器侧无 OS 级通知(仅剩会话行高亮)。宿主在无桌面的服务器上时 spawn 会静默失败,无害;测试页"测试宿主通知"返回真实结果,可据此验证宿主机桌面环境是否可用。聚焦时宿主通知照弹(宿主无法感知窗口焦点,聚焦静默是浏览器侧偏好)。
|
|
91
91
|
|
|
92
92
|
## IM 推送
|
|
93
93
|
|
|
94
|
-
安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot
|
|
94
|
+
安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot。IM 卡顶部有总开关,关闭后不向已配目标投递、目标绑定原样保留,临时停用不必逐个移除目标,再开即恢复。触发逻辑与 webhook 一致,fire-and-forget 不重试;bot 离线时该次通知弃置不补发。测试页的"测试 IM 通知"为显式点火,不受总开关影响。
|
|
95
95
|
|
|
96
96
|
## 会话行高亮
|
|
97
97
|
|
|
@@ -114,7 +114,7 @@ turn-notify:
|
|
|
114
114
|
| 通知 | webhook 地址、最短回合时长、子代理过滤、宿主通知(总开关与回退)、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
|
|
115
115
|
| 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
|
|
116
116
|
| 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
|
|
117
|
-
| IM |
|
|
117
|
+
| IM | IM 总开关(关闭保留目标)、绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
|
|
118
118
|
| 测试 | 声音/页内/系统/宿主/webhook/IM 逐通道点火,回执即真实结果 |
|
|
119
119
|
|
|
120
120
|
## settings.yaml 参考
|
|
@@ -127,8 +127,8 @@ turn-notify:
|
|
|
127
127
|
minTurnDurationMs: 5000 # 最短回合时长(毫秒),回合结束类通知短于此不送达
|
|
128
128
|
rootsOnly: true # 子代理会话不通知
|
|
129
129
|
suppressSubagentWake: true # 子代理相关回合不通知:后台委托未收尾或收尾唤醒(仅任务完成类)
|
|
130
|
-
hostNotify: false #
|
|
131
|
-
hostNotifyFallback: false #
|
|
130
|
+
hostNotify: false # 宿主机桌面通知总开关;浏览器优先,浏览器在场(2 秒内有在途长轮询)时让位
|
|
131
|
+
hostNotifyFallback: false # 宿主通知补位:浏览器离场(2 秒内无在途长轮询)时弹宿主桌面通知,与总开关判定一致
|
|
132
132
|
enabled: # 六类事件独立开关
|
|
133
133
|
completed: true
|
|
134
134
|
error: true
|
|
@@ -143,6 +143,7 @@ turn-notify:
|
|
|
143
143
|
imTargets: # dsh-im 投递目标,空数组禁用
|
|
144
144
|
- botId: wx_xxx
|
|
145
145
|
targetId: owner
|
|
146
|
+
imEnabled: true # IM 推送总开关:关闭后不投递,目标绑定保留
|
|
146
147
|
```
|
|
147
148
|
|
|
148
149
|
音量、聚焦静默、分类静音等本机偏好存浏览器 localStorage,不经 yaml。
|
|
@@ -153,18 +154,18 @@ turn-notify:
|
|
|
153
154
|
|
|
154
155
|
1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
|
|
155
156
|
2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
|
|
156
|
-
3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤)
|
|
157
|
+
3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤),不依赖浏览器;宿主通知按浏览器在场(在途长轮询,来源不限本机)浏览器优先去重,离场补位;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
|
|
157
158
|
4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
|
|
158
159
|
5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
|
|
159
160
|
|
|
160
161
|
## 已知取舍
|
|
161
162
|
|
|
162
163
|
- 标签页全关时,仅 webhook、IM 与宿主桌面通知(如开启)送达(声音与弹窗的浏览器前提)。
|
|
163
|
-
-
|
|
164
|
+
- 宿主桌面通知为浏览器优先去重:浏览器在场(2 秒内有在途长轮询)时宿主让位,边界情况(认可窗口边界、网络抖动)宁可不弹不误弹,优雅断连的离场盲区不超过 2 秒,异常断连受长轮询挂起上限约束(最坏约 25 秒);聚焦时宿主通知照弹(宿主无法感知窗口焦点);无桌面的宿主上 spawn 静默失败,无害。在场记账的形态校验(续传 cursor + 同源 fetch 形态)为启发式,防无意探活误压制与网页伪造在场(DNS rebinding 将域解析至 dsh 的页面同源可过校验,但该页面同时已握有全部写配置 API,通知压制非最大损失面),不构成对抗本机进程的安全边界。
|
|
164
165
|
- IM 依赖 dsh-im 的 bot 在线,离线时该次通知弃置不补发;成功回执仅代表平台受理。
|
|
165
166
|
- 不同浏览器或不同地址打开的窗口各自发声,互不去重。
|
|
166
167
|
- 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
|
|
167
|
-
-
|
|
168
|
+
- 长轮询空闲期连接由代理或服务端挂起,经中间反代时需允许数十秒的挂起响应,否则退化为更频繁的重连;反代还需透传 Host 头(如 nginx `proxy_set_header Host $host`),否则同源对账失败、真实浏览器不入账,宿主通知每次重复弹(失败方向与"宁可重复不可漏"一致)。
|
|
168
169
|
- 等待动作二次提醒的"已处理"判定以本窗口可见信号(会话切换/高亮清除)近似,无会话标题的通知无法感知处理,宁可多响一次。
|
|
169
170
|
- 点击直达依赖侧边栏会话行可被标题匹配命中,行未渲染或标题不同步时仅聚焦不切换。
|
|
170
171
|
|
|
@@ -189,3 +190,6 @@ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依
|
|
|
189
190
|
|
|
190
191
|
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。
|
|
191
192
|
|
|
193
|
+
## dsh 版本兼容
|
|
194
|
+
|
|
195
|
+
三版本全部通过:通知开关与播报形态、激活 live。0.1.6 控件数 13→19 属宿主演进,不构成不兼容。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -278,7 +278,7 @@ window.__ModuleLoader__.load({
|
|
|
278
278
|
const IDLE_AWAY_MS = 5 * 60 * 1000
|
|
279
279
|
|
|
280
280
|
// routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源。
|
|
281
|
-
//
|
|
281
|
+
// 宿主通知去重由浏览器优先模型承载:浏览器在场时宿主让位,本窗口照常弹系统通知
|
|
282
282
|
function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
|
|
283
283
|
const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
|
|
284
284
|
const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
|
|
@@ -1071,6 +1071,7 @@ window.__ModuleLoader__.load({
|
|
|
1071
1071
|
folderRunningEnabled: true,
|
|
1072
1072
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
|
|
1073
1073
|
imTargets: [],
|
|
1074
|
+
imEnabled: true,
|
|
1074
1075
|
}
|
|
1075
1076
|
|
|
1076
1077
|
const CSS = [
|
|
@@ -1616,6 +1617,18 @@ window.__ModuleLoader__.load({
|
|
|
1616
1617
|
void persistImTargets(removeImTargetFromList(config.imTargets, item.botId, item.targetId))
|
|
1617
1618
|
}
|
|
1618
1619
|
|
|
1620
|
+
// IM 总开关:目标绑定保留,关闭仅停投递;乐观回填,失败回滚
|
|
1621
|
+
function toggleImEnabled(checked) {
|
|
1622
|
+
setConfig((prev) => ({ ...prev, imEnabled: checked }))
|
|
1623
|
+
api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ imEnabled: checked }) })
|
|
1624
|
+
.then((res) => setConfig({ ...DEFAULT_CONFIG, ...res }))
|
|
1625
|
+
.catch((error) => {
|
|
1626
|
+
// 回滚到请求发出前的取值:两次异步 setConfig 闭包各自读到已更新的 prev
|
|
1627
|
+
setConfig((prev) => ({ ...prev, imEnabled: !prev.imEnabled }))
|
|
1628
|
+
patch('IM 开关切换失败:' + (error && error.message ? error.message : String(error)), 'error')
|
|
1629
|
+
})
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1619
1632
|
// 取消注册:移除该 bot 全部目标;bot 在 dsh-im 已删除时借此清理残留绑定
|
|
1620
1633
|
function unregisterImBot(botId) {
|
|
1621
1634
|
void persistImTargets(
|
|
@@ -1755,19 +1768,19 @@ window.__ModuleLoader__.load({
|
|
|
1755
1768
|
' 后台委托未收尾或收尾唤醒的回合不通知(仅完成类)'),
|
|
1756
1769
|
]),
|
|
1757
1770
|
field('宿主通知', [
|
|
1758
|
-
h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast)
|
|
1771
|
+
h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast);浏览器优先:有浏览器窗口在线(2 秒内有在途长轮询)时由浏览器呈现,宿主不重复弹;浏览器全关(标签页全关)时宿主弹' },
|
|
1759
1772
|
...switchToggle({
|
|
1760
1773
|
checked: config.hostNotify,
|
|
1761
1774
|
onChange: (e) => setConfig({ ...config, hostNotify: e.target.checked }),
|
|
1762
1775
|
}),
|
|
1763
1776
|
' 宿主机弹桌面通知'),
|
|
1764
|
-
h('label', { className: 'tn-meta tn-switch', title: '
|
|
1777
|
+
h('label', { className: 'tn-meta tn-switch', title: '浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,离场时宿主补位桌面通知;与总开关判定一致,任一开启即生效' },
|
|
1765
1778
|
...switchToggle({
|
|
1766
1779
|
checked: config.hostNotifyFallback,
|
|
1767
1780
|
onChange: (e) => setConfig({ ...config, hostNotifyFallback: e.target.checked }),
|
|
1768
1781
|
}),
|
|
1769
|
-
'
|
|
1770
|
-
], '由宿主进程直接弹 OS
|
|
1782
|
+
' 仅当无浏览器接收时补位'),
|
|
1783
|
+
], '由宿主进程直接弹 OS 桌面通知,服务器/浏览器全关场景可达;浏览器优先——浏览器在线时由浏览器呈现,宿主不重复弹,离场(2 秒内无在途长轮询)时宿主补位,边界情况(窗口边界与网络抖动)宁重复不漏。需点保存生效,测试页可逐条点火验证。'),
|
|
1771
1784
|
field('事件分类', h('div', { className: 'tn-pills' },
|
|
1772
1785
|
CATEGORIES.map((category) => h('span', {
|
|
1773
1786
|
className: 'tn-pill' + (config.enabled[category] ? ' tn-pill--on' : ''),
|
|
@@ -2002,6 +2015,14 @@ window.__ModuleLoader__.load({
|
|
|
2002
2015
|
h('span', { className: 'tn-card__title' }, 'IM 投递(dsh-im)'),
|
|
2003
2016
|
h('span', { className: 'tn-card__sub' }, '勾选目标即自动保存;支持绑定多个 bot,点 bot 名加载其目录,× 取消注册'),
|
|
2004
2017
|
),
|
|
2018
|
+
field('IM 开关', [
|
|
2019
|
+
h('label', { className: 'tn-meta tn-switch', title: '关闭后不再向已配目标投递 IM 通知,目标绑定保留;再次开启即恢复推送' },
|
|
2020
|
+
...switchToggle({
|
|
2021
|
+
checked: config.imEnabled,
|
|
2022
|
+
onChange: (e) => toggleImEnabled(e.target.checked),
|
|
2023
|
+
}),
|
|
2024
|
+
' 启用 IM 推送'),
|
|
2025
|
+
], '关闭时通知不推送 IM,已勾选的目标与 bot 绑定原样保留,无需移除目标'),
|
|
2005
2026
|
field('Bot ID', [
|
|
2006
2027
|
h('input', {
|
|
2007
2028
|
className: 'tn-input tn-fill', type: 'text',
|
package/src/core.mjs
CHANGED
|
@@ -499,35 +499,29 @@ const CONFIG_WEBHOOK_SCHEMES = ['http:', 'https:']
|
|
|
499
499
|
// 宿主桌面通知 spawn 超时:与 webhook 同语义,超时强杀不重试
|
|
500
500
|
export const HOST_NOTIFY_TIMEOUT_MS = 10 * 1000
|
|
501
501
|
|
|
502
|
-
//
|
|
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
|
-
// 宿主通知决策:浏览器优先——本机浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
502
|
+
// 宿主通知决策:浏览器优先——浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
510
503
|
// 零重复);离场由宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与
|
|
511
504
|
// 网络抖动),宁可重复不可漏。两开关均关不弹,任一开启即启用该通道。
|
|
512
|
-
export function hostNotifyWanted({ hostNotify, hostNotifyFallback,
|
|
505
|
+
export function hostNotifyWanted({ hostNotify, hostNotifyFallback, browserPresent }) {
|
|
513
506
|
if (hostNotify !== true && hostNotifyFallback !== true) return false
|
|
514
|
-
return
|
|
507
|
+
return browserPresent !== true
|
|
515
508
|
}
|
|
516
509
|
|
|
517
|
-
//
|
|
510
|
+
// 浏览器在场判定:有在途长轮询即在场信号(无论时间戳新旧);无在途时,
|
|
518
511
|
// 最近活动在认可窗口内(长轮询刚完成、断连刚出账)仍算在场,超窗或从未在场为假
|
|
519
|
-
export function
|
|
512
|
+
export function isBrowserPresent({ inFlightPolls, lastSeenAt, now, windowMs }) {
|
|
520
513
|
if (typeof inFlightPolls === 'number' && inFlightPolls > 0) return true
|
|
521
514
|
return typeof lastSeenAt === 'number' && lastSeenAt > 0 && now - lastSeenAt <= windowMs
|
|
522
515
|
}
|
|
523
516
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
517
|
+
// 浏览器轮询判定:长轮询续传形态(带 cursor)+ fetch 请求形态 + Origin 对账。
|
|
518
|
+
// 来源不限地址:浏览器可在任意机器访问 dsh(局域网部署),在场信号看的是
|
|
519
|
+
// "有无 dsh 页面在轮询",与浏览器同不同机无关。投影为无守卫 GET,记账须排除
|
|
520
|
+
// 可伪造在场的形态:no-cors(img 跨站探活)、无 cursor 的手工探测、跨源 fetch
|
|
521
|
+
// (恶意网页,sec-fetch-mode 同为 cors 无法区分,凭 Origin 与 Host 不符识别;
|
|
522
|
+
// 同源 GET fetch 不带 Origin,无 Origin 放行,旧浏览器同样覆盖)——只有 dsh
|
|
523
|
+
// 同源页面自身能发出合格形态,伪造面与来源地址无关
|
|
524
|
+
export function isBrowserPoll({ hasCursor, secFetchMode, origin, host }) {
|
|
531
525
|
if (hasCursor !== true) return false
|
|
532
526
|
if (secFetchMode !== undefined && secFetchMode !== 'cors') return false
|
|
533
527
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
@@ -641,7 +635,7 @@ export function imBoundBotIds(list) {
|
|
|
641
635
|
// 返回归一化后的补丁。
|
|
642
636
|
export function validateConfigPatch(patch) {
|
|
643
637
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
644
|
-
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
|
|
638
|
+
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'imEnabled', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
|
|
645
639
|
for (const key of Object.keys(patch)) {
|
|
646
640
|
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
647
641
|
}
|
|
@@ -681,6 +675,10 @@ export function validateConfigPatch(patch) {
|
|
|
681
675
|
}
|
|
682
676
|
next.imTargets = targets
|
|
683
677
|
}
|
|
678
|
+
if ('imEnabled' in patch) {
|
|
679
|
+
if (typeof patch.imEnabled !== 'boolean') return { ok: false, reason: 'imEnabled 须为布尔' }
|
|
680
|
+
next.imEnabled = patch.imEnabled
|
|
681
|
+
}
|
|
684
682
|
if ('minTurnDurationMs' in patch) {
|
|
685
683
|
const ms = patch.minTurnDurationMs
|
|
686
684
|
if (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 0) return { ok: false, reason: 'minTurnDurationMs 须为非负整数' }
|
|
@@ -775,6 +773,8 @@ export function resolvedConfig(settings) {
|
|
|
775
773
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
|
|
776
774
|
soundMapping,
|
|
777
775
|
imTargets: normalizeImTargets(source.imTargets),
|
|
776
|
+
// 开关缺省回开:存量配置升级行为不变
|
|
777
|
+
imEnabled: source.imEnabled !== false,
|
|
778
778
|
kindRoutes: routesVerdict.ok ? routesVerdict.routes : {},
|
|
779
779
|
}
|
|
780
780
|
}
|
|
@@ -792,6 +792,7 @@ export function publicConfig(settings) {
|
|
|
792
792
|
enabled: resolved.enabled,
|
|
793
793
|
soundMapping: resolved.soundMapping,
|
|
794
794
|
imTargets: resolved.imTargets,
|
|
795
|
+
imEnabled: resolved.imEnabled,
|
|
795
796
|
kindRoutes: resolved.kindRoutes,
|
|
796
797
|
webhookUrl: resolved.webhookUrl,
|
|
797
798
|
}
|
package/src/index.js
CHANGED
|
@@ -27,8 +27,8 @@ import {
|
|
|
27
27
|
isSubagentWakeTurn,
|
|
28
28
|
isValidImBotId,
|
|
29
29
|
hostNotifyWanted,
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
isBrowserPoll,
|
|
31
|
+
isBrowserPresent,
|
|
32
32
|
mapEventToCategory,
|
|
33
33
|
mimeOf,
|
|
34
34
|
normalizeImTargets,
|
|
@@ -62,7 +62,7 @@ const SOUNDS_DIR = join(homedir(), '.dsh', 'dsh-turn-notify', 'sounds')
|
|
|
62
62
|
const REQUEST_BODY_MAX_BYTES = 64 * 1024
|
|
63
63
|
// 长轮询挂起上限:低于客户端请求超时,保证客户端总在服务端放弃后才超时
|
|
64
64
|
const LONG_POLL_WAIT_MS = 25 * 1000
|
|
65
|
-
//
|
|
65
|
+
// 浏览器在场认可窗口:在途长轮询出账后的宽限时长,浏览器优先去重的时间边界;
|
|
66
66
|
// 窗口内事件仍视为浏览器呈现,超窗即宿主补位(宁可重复不可漏)
|
|
67
67
|
export const CLIENT_PRESENCE_WINDOW_MS = 2 * 1000
|
|
68
68
|
|
|
@@ -92,12 +92,13 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
92
92
|
minTurnDurationMs: z.number().default(MIN_TURN_DURATION_MS).description('最短回合时长(毫秒),回合结束类通知短于此不送达;AI 提问与审批请求即时送达'),
|
|
93
93
|
rootsOnly: z.boolean().default(true).description('子代理会话不通知'),
|
|
94
94
|
suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
|
|
95
|
-
hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast)
|
|
96
|
-
hostNotifyFallback: z.boolean().default(false).description('
|
|
95
|
+
hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
|
|
96
|
+
hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
|
|
97
97
|
folderRunningEnabled: z.boolean().default(true).description('工作区文件夹运行标记:侧边栏分组模式下,有运行中会话的文件夹组头显示运行点并给图标着色;关闭后标记整体不渲染,变更刷新页面生效'),
|
|
98
98
|
enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
|
|
99
99
|
soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
|
|
100
100
|
imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
|
|
101
|
+
imEnabled: z.boolean().default(true).description('IM 推送总开关:关闭后不向已配目标投递,目标绑定保留'),
|
|
101
102
|
kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im/host),未配置的分类全通道放行'),
|
|
102
103
|
})
|
|
103
104
|
|
|
@@ -208,10 +209,10 @@ export function apply(ctx) {
|
|
|
208
209
|
const titledSessions = new Set()
|
|
209
210
|
// 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
|
|
210
211
|
const assistantTails = new Map()
|
|
211
|
-
//
|
|
212
|
+
// 浏览器在场信号:在途长轮询计数与最近活动时刻,
|
|
212
213
|
// 在场 = 有在途请求或认可窗口内有活动,宿主通知的浏览器优先去重据此判定
|
|
213
|
-
let
|
|
214
|
-
let
|
|
214
|
+
let inFlightBrowserPolls = 0
|
|
215
|
+
let lastBrowserSeenAt = 0
|
|
215
216
|
let seq = 0
|
|
216
217
|
// 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
|
|
217
218
|
let soundWriteQueue = Promise.resolve()
|
|
@@ -274,16 +275,16 @@ export function apply(ctx) {
|
|
|
274
275
|
if (routes === null || routes.indexOf('host') >= 0) deliverHostNotify(unit, settings)
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
//
|
|
278
|
+
// 宿主桌面通知:浏览器优先,浏览器在场由浏览器呈现,宿主让位(正常路径
|
|
278
279
|
// 零重复);离场(无在途长轮询且超认可窗口)由宿主补位;fire-and-forget,
|
|
279
280
|
// spawn 失败不影响其余通道
|
|
280
281
|
function deliverHostNotify(unit, settings) {
|
|
281
282
|
const wanted = hostNotifyWanted({
|
|
282
283
|
hostNotify: settings.hostNotify,
|
|
283
284
|
hostNotifyFallback: settings.hostNotifyFallback,
|
|
284
|
-
|
|
285
|
-
inFlightPolls:
|
|
286
|
-
lastSeenAt:
|
|
285
|
+
browserPresent: isBrowserPresent({
|
|
286
|
+
inFlightPolls: inFlightBrowserPolls,
|
|
287
|
+
lastSeenAt: lastBrowserSeenAt,
|
|
287
288
|
now: Date.now(),
|
|
288
289
|
windowMs: CLIENT_PRESENCE_WINDOW_MS,
|
|
289
290
|
}),
|
|
@@ -311,6 +312,8 @@ export function apply(ctx) {
|
|
|
311
312
|
function deliverIm(unit, settings) {
|
|
312
313
|
const dshIm = ctx.get('dshIm')
|
|
313
314
|
if (dshIm === undefined) return
|
|
315
|
+
// 总开关关闭:目标绑定保留,仅不投递
|
|
316
|
+
if (settings.imEnabled === false) return
|
|
314
317
|
for (const { botId, targetId } of normalizeImTargets(settings.imTargets)) {
|
|
315
318
|
void Promise.resolve().then(() => dshIm.send(botId, targetId, unit.text, { signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS) })).catch(() => {})
|
|
316
319
|
}
|
|
@@ -441,27 +444,26 @@ export function apply(ctx) {
|
|
|
441
444
|
path: '/api/turn-notify/projection',
|
|
442
445
|
handler: route('GET', {}, async (req, res) => {
|
|
443
446
|
const query = new URL(req.url, 'http://localhost').searchParams
|
|
444
|
-
//
|
|
447
|
+
// 浏览器长轮询即在场信号,宿主通知的浏览器优先去重据此判定;
|
|
445
448
|
// 记账形态收紧防伪造:裸 GET(img 探活/监控探测)与跨源 fetch 不入账。
|
|
446
449
|
// 记账期间计入在途,断连(浏览器关闭/中止)经 res close 事件即时出账,
|
|
447
450
|
// 离场盲区收敛到认可窗口
|
|
448
|
-
let
|
|
449
|
-
if (
|
|
450
|
-
remoteAddress: req.socket && req.socket.remoteAddress,
|
|
451
|
+
let releaseBrowserPoll = null
|
|
452
|
+
if (isBrowserPoll({
|
|
451
453
|
hasCursor: query.has('cursor'),
|
|
452
454
|
secFetchMode: req.headers ? req.headers['sec-fetch-mode'] : undefined,
|
|
453
455
|
origin: req.headers ? req.headers.origin : undefined,
|
|
454
456
|
host: req.headers ? req.headers.host : undefined,
|
|
455
457
|
})) {
|
|
456
|
-
|
|
458
|
+
inFlightBrowserPolls += 1
|
|
457
459
|
let settled = false
|
|
458
|
-
|
|
460
|
+
releaseBrowserPoll = () => {
|
|
459
461
|
if (settled) return
|
|
460
462
|
settled = true
|
|
461
|
-
|
|
462
|
-
|
|
463
|
+
inFlightBrowserPolls -= 1
|
|
464
|
+
lastBrowserSeenAt = Date.now()
|
|
463
465
|
}
|
|
464
|
-
res.once('close',
|
|
466
|
+
res.once('close', releaseBrowserPoll)
|
|
465
467
|
}
|
|
466
468
|
try {
|
|
467
469
|
const rawCursor = Number(query.get('cursor'))
|
|
@@ -472,7 +474,7 @@ export function apply(ctx) {
|
|
|
472
474
|
const settings = readSettings(ctx)
|
|
473
475
|
sendJson(res, 200, { units: projection.list(), soundMapping: settings.soundMapping, version: projection.version() })
|
|
474
476
|
} finally {
|
|
475
|
-
if (
|
|
477
|
+
if (releaseBrowserPoll !== null) releaseBrowserPoll()
|
|
476
478
|
}
|
|
477
479
|
}),
|
|
478
480
|
})
|
|
@@ -758,6 +760,7 @@ export function apply(ctx) {
|
|
|
758
760
|
sendJson(res, 200, { ok: false, detail: '未配置投递目标' })
|
|
759
761
|
return
|
|
760
762
|
}
|
|
763
|
+
// 点火测试与开关无关:显式验证通道连通,不受投递总开关压制
|
|
761
764
|
// 逐目标结算,真实结果随响应返回,与 test-webhook 的不谎报原则一致
|
|
762
765
|
const results = await Promise.all(targets.map(async ({ botId, targetId }) => {
|
|
763
766
|
try {
|
package/test/core.test.mjs
CHANGED
|
@@ -646,6 +646,10 @@ test('配置补丁校验:非法输入逐类拒绝', () => {
|
|
|
646
646
|
assert.equal(validateConfigPatch({ minTurnDurationMs: 'fast' }).ok, false)
|
|
647
647
|
assert.equal(validateConfigPatch({ rootsOnly: 'yes' }).ok, false)
|
|
648
648
|
assert.equal(validateConfigPatch({ suppressSubagentWake: 'yes' }).ok, false)
|
|
649
|
+
// imEnabled:布尔放行,非布尔拒绝
|
|
650
|
+
assert.deepEqual(validateConfigPatch({ imEnabled: false }), { ok: true, patch: { imEnabled: false } })
|
|
651
|
+
assert.equal(validateConfigPatch({ imEnabled: 'yes' }).ok, false)
|
|
652
|
+
assert.equal(validateConfigPatch({ imEnabled: 1 }).ok, false)
|
|
649
653
|
assert.equal(validateConfigPatch({ enabled: { unknown: true } }).ok, false)
|
|
650
654
|
assert.equal(validateConfigPatch({ enabled: { completed: 'no' } }).ok, false)
|
|
651
655
|
assert.equal(validateConfigPatch({ enabled: [true] }).ok, false)
|
|
@@ -665,6 +669,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
665
669
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
666
670
|
soundMapping: { completed: 'snd-1' },
|
|
667
671
|
imTargets: [],
|
|
672
|
+
imEnabled: true,
|
|
668
673
|
kindRoutes: { completed: ['webhook'] },
|
|
669
674
|
},
|
|
670
675
|
)
|
|
@@ -679,10 +684,14 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
679
684
|
enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
|
|
680
685
|
soundMapping: {},
|
|
681
686
|
imTargets: [],
|
|
687
|
+
imEnabled: true,
|
|
682
688
|
kindRoutes: {},
|
|
683
689
|
})
|
|
684
690
|
assert.equal(resolvedConfig({ minTurnDurationMs: Number.NaN }).minTurnDurationMs, MIN_TURN_MS)
|
|
685
691
|
assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
|
|
692
|
+
// imEnabled:显式 false 生效,缺省或类型异常回开(升级兼容)
|
|
693
|
+
assert.equal(resolvedConfig({ imEnabled: false }).imEnabled, false)
|
|
694
|
+
assert.equal(resolvedConfig({ imEnabled: 'no' }).imEnabled, true)
|
|
686
695
|
})
|
|
687
696
|
|
|
688
697
|
test('面板可见配置:webhookUrl 原文回显', () => {
|
|
@@ -698,6 +707,7 @@ test('面板可见配置:webhookUrl 原文回显', () => {
|
|
|
698
707
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
699
708
|
soundMapping: { completed: 'snd-1' },
|
|
700
709
|
imTargets: [],
|
|
710
|
+
imEnabled: true,
|
|
701
711
|
kindRoutes: { ask: ['im'] },
|
|
702
712
|
webhookUrl: 'https://hook.example/service/xxx',
|
|
703
713
|
},
|
|
@@ -377,6 +377,27 @@ test('Given dshIm 在场且已配多目标 When 回合完成 Then 逐目标投
|
|
|
377
377
|
assert.ok(sends.every((call) => String(call.text).startsWith('[dsh]')), 'IM 文本应与通知单元一致')
|
|
378
378
|
})
|
|
379
379
|
|
|
380
|
+
test('Given 已配目标且 imEnabled=false When 回合完成 Then 不投递且目标保留', async () => {
|
|
381
|
+
const sends = []
|
|
382
|
+
const { ctx, routes, handlers } = makeCtx({ dshIm: makeImDshIm(sends) })
|
|
383
|
+
apply(ctx)
|
|
384
|
+
await disableDurationFilter(routes)
|
|
385
|
+
await configureImTargets(routes, [{ botId: 'wx_a', targetId: 'owner' }, { botId: 'wx_b', targetId: 'group' }])
|
|
386
|
+
const offRes = makeRes()
|
|
387
|
+
await routes.get('/api/turn-notify/config')(makeReq('POST', { imEnabled: false }, JSON_HEADERS), offRes)
|
|
388
|
+
assert.equal(offRes.status, 200)
|
|
389
|
+
assert.equal(offRes.body.imEnabled, false)
|
|
390
|
+
assert.equal(offRes.body.imTargets.length, 2, '开关关闭不应清空目标')
|
|
391
|
+
const onEvent = handlers.get('session/event')
|
|
392
|
+
onEvent(MAIN, { type: 'turn/start' })
|
|
393
|
+
onEvent(MAIN, turnEnd('completed'))
|
|
394
|
+
await flushMicrotasks()
|
|
395
|
+
assert.equal(sends.length, 0, 'imEnabled=false 时不投递')
|
|
396
|
+
// 投影照常:开关只压制 IM 通道
|
|
397
|
+
const units = await projectionUnits(routes)
|
|
398
|
+
assert.equal(units.filter((unit) => unit.category === 'completed').length, 1)
|
|
399
|
+
})
|
|
400
|
+
|
|
380
401
|
test('Given send 拒绝 When 回合完成 Then 无未处理拒绝且投影照常', async () => {
|
|
381
402
|
const rejections = []
|
|
382
403
|
const onUnhandled = (reason) => rejections.push(reason)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 宿主桌面通知 BDD:core 纯函数(
|
|
1
|
+
// 宿主桌面通知 BDD:core 纯函数(派发决策 / 平台命令 / spawn 执行 / 配置键)
|
|
2
2
|
// 与 host 接线集成(stub ctx 装载真实 src/index.js,stub spawn 观测派发)。
|
|
3
3
|
import test from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
@@ -10,53 +10,32 @@ import {
|
|
|
10
10
|
HOST_NOTIFY_TIMEOUT_MS,
|
|
11
11
|
hostNotifyCommand,
|
|
12
12
|
hostNotifyWanted,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
isLoopbackAddress,
|
|
13
|
+
isBrowserPoll,
|
|
14
|
+
isBrowserPresent,
|
|
16
15
|
publicConfig,
|
|
17
16
|
resolvedConfig,
|
|
18
17
|
sendHostNotify,
|
|
19
18
|
validateConfigPatch,
|
|
20
19
|
} from '../src/core.mjs'
|
|
21
20
|
|
|
22
|
-
// ----
|
|
21
|
+
// ---- isBrowserPoll:在场记账形态收紧,防伪造在场压制宿主通知 ----
|
|
23
22
|
|
|
24
|
-
test('Given
|
|
25
|
-
assert.equal(
|
|
26
|
-
assert.equal(
|
|
27
|
-
assert.equal(isLoopbackAddress('127.8.8.8'), true)
|
|
28
|
-
assert.equal(isLoopbackAddress('::ffff:127.0.0.1'), true)
|
|
23
|
+
test('Given 合格轮询形态(任意来源加 cursor 加 fetch 形态或无 Sec-Fetch 头)When 判定 Then 为真', () => {
|
|
24
|
+
assert.equal(isBrowserPoll({ hasCursor: true, secFetchMode: 'cors' }), true)
|
|
25
|
+
assert.equal(isBrowserPoll({ hasCursor: true, secFetchMode: undefined }), true)
|
|
29
26
|
})
|
|
30
27
|
|
|
31
|
-
test('Given
|
|
32
|
-
assert.equal(
|
|
33
|
-
assert.equal(
|
|
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)
|
|
28
|
+
test('Given 无 cursor 或 no-cors 形态 When 判定 Then 为假', () => {
|
|
29
|
+
assert.equal(isBrowserPoll({ hasCursor: false, secFetchMode: 'cors' }), false)
|
|
30
|
+
assert.equal(isBrowserPoll({ hasCursor: true, secFetchMode: 'no-cors' }), false)
|
|
52
31
|
})
|
|
53
32
|
|
|
54
33
|
test('Given Origin 缺席或与 Host 一致 When 判定 Then 为真;跨源或畸形 Origin Then 为假', () => {
|
|
55
|
-
const base = {
|
|
56
|
-
assert.equal(
|
|
57
|
-
assert.equal(
|
|
58
|
-
assert.equal(
|
|
59
|
-
assert.equal(
|
|
34
|
+
const base = { hasCursor: true, secFetchMode: 'cors' }
|
|
35
|
+
assert.equal(isBrowserPoll({ ...base, origin: undefined, host: '127.0.0.1:3080' }), true, '同源 GET fetch 不带 Origin')
|
|
36
|
+
assert.equal(isBrowserPoll({ ...base, origin: 'http://127.0.0.1:3080', host: '127.0.0.1:3080' }), true)
|
|
37
|
+
assert.equal(isBrowserPoll({ ...base, origin: 'https://evil.example', host: '127.0.0.1:3080' }), false, '跨源 fetch 必带且与 Host 不符')
|
|
38
|
+
assert.equal(isBrowserPoll({ ...base, origin: 'not a url', host: '127.0.0.1:3080' }), false, '畸形 Origin 不放行')
|
|
60
39
|
})
|
|
61
40
|
|
|
62
41
|
// ---- hostNotifyWanted:浏览器优先,在场让位、离场补位;重复只发生在边界情况 ----
|
|
@@ -64,36 +43,36 @@ test('Given Origin 缺席或与 Host 一致 When 判定 Then 为真;跨源或畸
|
|
|
64
43
|
const NOW = 1000 * 1000
|
|
65
44
|
const WINDOW_MS = 2 * 1000
|
|
66
45
|
|
|
67
|
-
test('Given
|
|
68
|
-
assert.equal(hostNotifyWanted({ hostNotify: true,
|
|
69
|
-
assert.equal(hostNotifyWanted({ hostNotifyFallback: true,
|
|
70
|
-
assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true,
|
|
46
|
+
test('Given 任一开关开且浏览器不在场 When 决策 Then 弹', () => {
|
|
47
|
+
assert.equal(hostNotifyWanted({ hostNotify: true, browserPresent: false }), true)
|
|
48
|
+
assert.equal(hostNotifyWanted({ hostNotifyFallback: true, browserPresent: false }), true)
|
|
49
|
+
assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, browserPresent: false }), true)
|
|
71
50
|
})
|
|
72
51
|
|
|
73
|
-
test('Given
|
|
74
|
-
assert.equal(hostNotifyWanted({ hostNotify: true,
|
|
75
|
-
assert.equal(hostNotifyWanted({ hostNotifyFallback: true,
|
|
76
|
-
assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true,
|
|
52
|
+
test('Given 任一开关开且浏览器在场 When 决策 Then 让位不弹(浏览器优先,正常路径零重复)', () => {
|
|
53
|
+
assert.equal(hostNotifyWanted({ hostNotify: true, browserPresent: true }), false)
|
|
54
|
+
assert.equal(hostNotifyWanted({ hostNotifyFallback: true, browserPresent: true }), false)
|
|
55
|
+
assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, browserPresent: true }), false)
|
|
77
56
|
})
|
|
78
57
|
|
|
79
58
|
test('Given 两开关均关 When 决策 Then 恒不弹', () => {
|
|
80
|
-
assert.equal(hostNotifyWanted({
|
|
81
|
-
assert.equal(hostNotifyWanted({ hostNotify: false, hostNotifyFallback: false,
|
|
59
|
+
assert.equal(hostNotifyWanted({ browserPresent: false }), false)
|
|
60
|
+
assert.equal(hostNotifyWanted({ hostNotify: false, hostNotifyFallback: false, browserPresent: false }), false)
|
|
82
61
|
})
|
|
83
62
|
|
|
84
|
-
// ----
|
|
63
|
+
// ---- isBrowserPresent:在场 = 有在途长轮询,或宽限窗口内有活动 ----
|
|
85
64
|
|
|
86
65
|
test('Given 在途长轮询存在 When 判定在场 Then 为真(时间戳过期不影响)', () => {
|
|
87
|
-
assert.equal(
|
|
88
|
-
assert.equal(
|
|
66
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 1, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), true)
|
|
67
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 3, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), true)
|
|
89
68
|
})
|
|
90
69
|
|
|
91
70
|
test('Given 无在途 When 判定在场 Then 最近活动在窗口内为真,超窗或从未为假', () => {
|
|
92
|
-
assert.equal(
|
|
93
|
-
assert.equal(
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
96
|
-
assert.equal(
|
|
71
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - 1, now: NOW, windowMs: WINDOW_MS }), true)
|
|
72
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS, now: NOW, windowMs: WINDOW_MS }), true)
|
|
73
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), false)
|
|
74
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 0, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), false)
|
|
75
|
+
assert.equal(isBrowserPresent({ inFlightPolls: 0, lastSeenAt: undefined, now: NOW, windowMs: WINDOW_MS }), false)
|
|
97
76
|
})
|
|
98
77
|
|
|
99
78
|
// ---- hostNotifyCommand:平台命令构造与文本转义 ----
|
|
@@ -209,7 +188,7 @@ test('Given 缺省配置 When 解析 Then 两键回 false;CHANNELS 含 host', ()
|
|
|
209
188
|
assert.ok(CHANNELS.indexOf('host') >= 0, 'host 应为可路由通道')
|
|
210
189
|
})
|
|
211
190
|
|
|
212
|
-
// ---- host 接线集成:stub spawn 观测派发,presence
|
|
191
|
+
// ---- host 接线集成:stub spawn 观测派发,presence 经投影路由的请求形态注入 ----
|
|
213
192
|
|
|
214
193
|
// res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once/emit;
|
|
215
194
|
// end 不自动触发 close,断连时机由测试显式 emit 模拟
|
|
@@ -272,30 +251,31 @@ async function postConfig(routes, patch) {
|
|
|
272
251
|
return res
|
|
273
252
|
}
|
|
274
253
|
|
|
275
|
-
async function pollProjection(routes
|
|
254
|
+
async function pollProjection(routes) {
|
|
276
255
|
const req = makeReq('GET')
|
|
277
256
|
req.url = '/api/turn-notify/projection'
|
|
278
|
-
if (remoteAddress !== undefined) req.socket = { remoteAddress }
|
|
279
257
|
const res = makeRes()
|
|
280
258
|
await routes.get('/api/turn-notify/projection')(req, res)
|
|
281
259
|
assert.equal(res.status, 200)
|
|
282
260
|
return res
|
|
283
261
|
}
|
|
284
262
|
|
|
285
|
-
//
|
|
286
|
-
// cursor
|
|
263
|
+
// 浏览器长轮询记账形态:任意来源 + 带续传 cursor + fetch 形态头;
|
|
264
|
+
// cursor 取超前值立即返回,不挂起。跨机地址为主场景(真实部署拓扑:
|
|
265
|
+
// 浏览器在局域网另一台机器访问 dsh),回环为同机特例
|
|
287
266
|
const FUTURE_CURSOR = 2 ** 40
|
|
288
|
-
|
|
267
|
+
const REMOTE_BROWSER = '10.123.90.14'
|
|
268
|
+
async function browserPoll(routes) {
|
|
289
269
|
const req = makeReq('GET', undefined, { 'sec-fetch-mode': 'cors' })
|
|
290
270
|
req.url = '/api/turn-notify/projection?cursor=' + FUTURE_CURSOR
|
|
291
|
-
req.socket = { remoteAddress }
|
|
271
|
+
req.socket = { remoteAddress: REMOTE_BROWSER }
|
|
292
272
|
const res = makeRes()
|
|
293
273
|
await routes.get('/api/turn-notify/projection')(req, res)
|
|
294
274
|
assert.equal(res.status, 200)
|
|
295
275
|
return res
|
|
296
276
|
}
|
|
297
277
|
|
|
298
|
-
//
|
|
278
|
+
// 伪形态轮询:缺 cursor / no-cors / 跨源 Origin,记账须拒绝(与来源地址无关)
|
|
299
279
|
async function rawShapePoll(routes, remoteAddress, secFetchMode, withCursor, origin) {
|
|
300
280
|
const headers = secFetchMode === undefined ? {} : { 'sec-fetch-mode': secFetchMode }
|
|
301
281
|
if (origin !== undefined) headers.origin = origin
|
|
@@ -330,9 +310,11 @@ async function emitCompletedTurn(handlers) {
|
|
|
330
310
|
}
|
|
331
311
|
|
|
332
312
|
// 挂起中的长轮询:cursor 等于当前版本(配置 POST 后为 1),服务端挂起等待,
|
|
333
|
-
// 模拟真实客户端的在途请求;返回 pending 承诺与 res 桩(供断连模拟与唤醒等待)
|
|
334
|
-
|
|
335
|
-
|
|
313
|
+
// 模拟真实客户端的在途请求;返回 pending 承诺与 res 桩(供断连模拟与唤醒等待);
|
|
314
|
+
// secFetchMode 缺省 cors,传 undefined 模拟不发 Sec-Fetch 头的旧浏览器
|
|
315
|
+
function hangingPoll(routes, remoteAddress, secFetchMode) {
|
|
316
|
+
const headers = secFetchMode === undefined ? {} : { 'sec-fetch-mode': secFetchMode }
|
|
317
|
+
const req = makeReq('GET', undefined, headers)
|
|
336
318
|
req.url = '/api/turn-notify/projection?cursor=1'
|
|
337
319
|
req.socket = { remoteAddress }
|
|
338
320
|
const res = makeRes()
|
|
@@ -340,39 +322,53 @@ function hangingPoll(routes, remoteAddress) {
|
|
|
340
322
|
return { pending, res }
|
|
341
323
|
}
|
|
342
324
|
|
|
343
|
-
test('Given
|
|
325
|
+
test('Given 旧浏览器形态(无 Sec-Fetch 头)跨机长轮询在途 When 回合完成 Then 宿主让位', async () => {
|
|
326
|
+
await withStubSpawn(async (spawns) => {
|
|
327
|
+
const { ctx, routes, handlers } = makeCtx()
|
|
328
|
+
apply(ctx)
|
|
329
|
+
await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
|
|
330
|
+
const { pending } = hangingPoll(routes, REMOTE_BROWSER, undefined)
|
|
331
|
+
await flushMicrotasks()
|
|
332
|
+
await emitCompletedTurn(handlers)
|
|
333
|
+
assert.equal(spawns.length, 0, '无 Sec-Fetch 头的同源续传轮询同样入账,宿主让位')
|
|
334
|
+
await pending
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('Given 总开关开且跨机长轮询在途 When 回合完成 Then 宿主让位;离场超窗后接管', async () => {
|
|
344
339
|
await withStubSpawn(async (spawns) => {
|
|
345
340
|
const { ctx, routes, handlers } = makeCtx()
|
|
346
341
|
apply(ctx)
|
|
347
342
|
const saved = await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
|
|
348
343
|
assert.equal(saved.body.hostNotify, true)
|
|
349
|
-
const { pending, res } = hangingPoll(routes,
|
|
344
|
+
const { pending, res } = hangingPoll(routes, REMOTE_BROWSER)
|
|
350
345
|
await flushMicrotasks()
|
|
351
|
-
//
|
|
346
|
+
// 在途请求即在场信号:浏览器优先,宿主让位,正常路径零重复;
|
|
347
|
+
// 浏览器在局域网另一台机器访问时同样让位
|
|
352
348
|
await emitCompletedTurn(handlers)
|
|
353
|
-
assert.equal(spawns.length, 0, '
|
|
349
|
+
assert.equal(spawns.length, 0, '跨机浏览器长轮询在途,宿主让位')
|
|
354
350
|
// 在途请求被回合事件唤醒完成,宽限窗口内仍算在场
|
|
355
351
|
await pending
|
|
356
352
|
await emitCompletedTurn(handlers)
|
|
357
353
|
assert.equal(spawns.length, 0, '长轮询刚完成,宽限窗口内仍由浏览器呈现')
|
|
358
|
-
//
|
|
354
|
+
// 推进时钟越过宽限窗口:浏览器视为离场,宿主接管(宁可重复不可漏)
|
|
359
355
|
const realNow = Date.now
|
|
360
356
|
Date.now = () => realNow() + CLIENT_PRESENCE_WINDOW_MS + 1
|
|
361
357
|
try {
|
|
362
358
|
await emitCompletedTurn(handlers)
|
|
363
359
|
} finally { Date.now = realNow }
|
|
364
360
|
assert.equal(spawns.length, 1, '离场超窗,宿主接管')
|
|
365
|
-
const projection = await pollProjection(routes
|
|
361
|
+
const projection = await pollProjection(routes)
|
|
366
362
|
assert.equal(projection.body.units.length, 3, '浏览器呈现通道不受让位影响')
|
|
367
363
|
})
|
|
368
364
|
})
|
|
369
365
|
|
|
370
|
-
test('Given
|
|
366
|
+
test('Given 总开关开且长轮询断连 When 回合完成 Then 认可窗口内让位,超窗接管', async () => {
|
|
371
367
|
await withStubSpawn(async (spawns) => {
|
|
372
368
|
const { ctx, routes, handlers } = makeCtx()
|
|
373
369
|
apply(ctx)
|
|
374
370
|
await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
|
|
375
|
-
const { pending, res } = hangingPoll(routes,
|
|
371
|
+
const { pending, res } = hangingPoll(routes, REMOTE_BROWSER)
|
|
376
372
|
await flushMicrotasks()
|
|
377
373
|
// 浏览器关闭中止请求:close 事件即时出账,最近活动定格于断连时刻
|
|
378
374
|
res.emit('close')
|
|
@@ -401,7 +397,7 @@ test('Given 记账轮询正常完成后再触发 close When 时钟推进越过
|
|
|
401
397
|
let fake = 1000 * 1000
|
|
402
398
|
Date.now = () => fake
|
|
403
399
|
try {
|
|
404
|
-
const first = await browserPoll(routes
|
|
400
|
+
const first = await browserPoll(routes)
|
|
405
401
|
// 生产正常完成路径:finally 已出账,响应完成后的 close 再触发须被幂等消化;
|
|
406
402
|
// 若双出账致计数走负,时钟推进后本应让位的在途轮询将误弹
|
|
407
403
|
first.emit('close')
|
|
@@ -427,8 +423,8 @@ test('Given 双 tab 并发挂起其一断连 When 时钟推进越过认可窗 Th
|
|
|
427
423
|
let fake = 1000 * 1000
|
|
428
424
|
Date.now = () => fake
|
|
429
425
|
try {
|
|
430
|
-
const tabA = hangingPoll(routes,
|
|
431
|
-
const tabB = hangingPoll(routes, '
|
|
426
|
+
const tabA = hangingPoll(routes, REMOTE_BROWSER)
|
|
427
|
+
const tabB = hangingPoll(routes, '127.0.0.1')
|
|
432
428
|
await flushMicrotasks()
|
|
433
429
|
// tabA 断连即时出账,tabB 独力承载在途信号
|
|
434
430
|
tabA.res.emit('close')
|
|
@@ -445,15 +441,23 @@ test('Given 双 tab 并发挂起其一断连 When 时钟推进越过认可窗 Th
|
|
|
445
441
|
})
|
|
446
442
|
})
|
|
447
443
|
|
|
448
|
-
test('Given
|
|
444
|
+
test('Given 回退开关开 When 跨机长轮询在途 Then 不弹;无浏览器在场 Then 弹', async () => {
|
|
449
445
|
await withStubSpawn(async (spawns) => {
|
|
450
|
-
const
|
|
451
|
-
apply(ctx)
|
|
452
|
-
await postConfig(routes, {
|
|
453
|
-
|
|
454
|
-
await
|
|
455
|
-
|
|
456
|
-
assert.
|
|
446
|
+
const local = makeCtx()
|
|
447
|
+
apply(local.ctx)
|
|
448
|
+
await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
|
|
449
|
+
const localPoll = hangingPoll(local.routes, REMOTE_BROWSER)
|
|
450
|
+
await flushMicrotasks()
|
|
451
|
+
await emitCompletedTurn(local.handlers)
|
|
452
|
+
assert.equal(spawns.length, 0, '跨机浏览器在途,回退不触发')
|
|
453
|
+
await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
|
|
454
|
+
await localPoll.pending
|
|
455
|
+
// 独立实例:从未有浏览器轮询
|
|
456
|
+
const remote = makeCtx()
|
|
457
|
+
apply(remote.ctx)
|
|
458
|
+
await postConfig(remote.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
|
|
459
|
+
await emitCompletedTurn(remote.handlers)
|
|
460
|
+
assert.equal(spawns.length, 1, '无浏览器在场,回退触发')
|
|
457
461
|
})
|
|
458
462
|
})
|
|
459
463
|
|
|
@@ -477,40 +481,19 @@ test('Given kindRoutes 名单不含 host When 总开关开 Then 不弹', async (
|
|
|
477
481
|
})
|
|
478
482
|
})
|
|
479
483
|
|
|
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
484
|
test('Given 裸 GET 形态(无 cursor 或 no-cors 或跨源)When 回合完成 Then 回退不触发(伪造在场不入账)', async () => {
|
|
502
485
|
await withStubSpawn(async (spawns) => {
|
|
503
486
|
const { ctx, routes, handlers } = makeCtx()
|
|
504
487
|
apply(ctx)
|
|
505
488
|
await postConfig(routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
|
|
506
|
-
//
|
|
507
|
-
await rawShapePoll(routes,
|
|
508
|
-
//
|
|
509
|
-
await rawShapePoll(routes,
|
|
510
|
-
//
|
|
511
|
-
await rawShapePoll(routes,
|
|
489
|
+
// 浏览器来源但缺 cursor:手工探测形态
|
|
490
|
+
await rawShapePoll(routes, REMOTE_BROWSER, 'cors', false)
|
|
491
|
+
// 浏览器来源带 cursor 但 no-cors:dsh 同源网页的 img 探活形态
|
|
492
|
+
await rawShapePoll(routes, REMOTE_BROWSER, 'no-cors', true)
|
|
493
|
+
// 浏览器来源带 cursor 且 cors 但跨源 Origin:任意机器上恶意网页的标准 fetch 形态
|
|
494
|
+
await rawShapePoll(routes, REMOTE_BROWSER, 'cors', true, 'https://evil.example')
|
|
512
495
|
await emitCompletedTurn(handlers)
|
|
513
|
-
assert.equal(spawns.length, 1, '
|
|
496
|
+
assert.equal(spawns.length, 1, '伪造在场不入账,无真实浏览器时回退照常触发')
|
|
514
497
|
})
|
|
515
498
|
})
|
|
516
499
|
|
|
@@ -529,7 +512,7 @@ test('Given spawn 失败 When 回合完成 Then 无未处理拒绝且投影照
|
|
|
529
512
|
await flushMicrotasks()
|
|
530
513
|
await flushMicrotasks()
|
|
531
514
|
assert.equal(rejections.length, 0)
|
|
532
|
-
const projection = await pollProjection(routes
|
|
515
|
+
const projection = await pollProjection(routes)
|
|
533
516
|
assert.equal(projection.body.units.length, 1)
|
|
534
517
|
} finally {
|
|
535
518
|
process.off('unhandledRejection', onUnhandled)
|