@mzzsfy/dsh-turn-notify 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
44
44
  - **8 种内置音色**:上行琶音、铃铛、清脆双音、警报方波、低鸣、双音提示、嘀嗒、低音下滑。由 Web Audio 实时合成,零下载、零系统依赖。浏览器自动播放策略要求先与页面交互一次(任意点击),首次交互前声音静默,其余通道不受影响。
45
45
  - **自定义音效上传**:支持 wav / mp3 / ogg,可一次多选,单文件上限 2MB、总库上限 10MB。上传前可逐个试听,确认后才落盘保存。支持重命名;同一文件重复上传自动识别,不产生重复文件。
46
46
  - **按事件分类指定音效**:六类事件各自映射一款音效(内置或上传均可),例如"任务完成"用上行琶音、"等待审批"用双音提示。删除音效时引用它的映射自动清空、回落内置默认;仅当映射指向不存在的音效(如手改 yaml 产生死链)时,面板才显示失效项,试听时给出归因提示。
47
- - **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"出声。被静音分类的系统弹窗与标题闪烁随之静默,页内提示卡片与会话高亮不受影响;页内提示音有独立的分类开关,不随此处变化。
47
+ - **按分类静音**:总开关之外,可单独让某一类事件不出声,例如只对"AI 提问""等待审批"出声。静音只关闭声音通道本身,系统弹窗与标题闪烁照常;页内提示卡片与会话高亮不受影响;页内提示音有独立的分类开关,不随此处变化。
48
48
  - **音量**:按 5% 步进 0-100% 可调,本机记忆。
49
49
  - **映射双作用域**:音效映射默认全局共用(存 settings.yaml,所有浏览器一致);打开"当前域名独立"后,映射改动只写本浏览器(按域名隔离),公司/家里各配各的,本地优先于全局。关闭开关即恢复全局,本地已存映射休眠保留,重新开启即恢复。
50
50
  - **试听**:音效库与分类映射的每一项都能即时试听,试听的就是实际生效的音效。
@@ -56,7 +56,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
56
56
  - **系统弹窗**:浏览器 Notification 授权后,窗口失焦时弹系统级通知。HTTP 非回环地址下浏览器不提供该能力,声音开启时自动降级为标题闪烁;HTTPS 下自动恢复。Windows 下还受系统通知设置与专注助手约束。
57
57
  - **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒),聚焦窗口内唯一常开的提醒形态,可关。
58
58
  - **页内提示音**:聚焦场景的独立声音,总开关、按事件分类独立开关与音色映射全套自成一档,独立于提示音的分类配置。开启后页内提示弹出且通知声音未播时补一声提示——聚焦窗口内通知声音被聚焦静默压制,靠它保留听觉提醒;与通知声音互斥,同一通知至多一声;音量共用,音色在音效页的「页内提示音映射」单独指定。
59
- - **降级标题闪烁**:声音通道开启(总开关开且该类未静音)的前提下,系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;可在偏好中关闭该提示。
59
+ - **降级标题闪烁**:系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;单条闪烁 6 秒自停,连环通知逐条续命但总时长硬顶 30 秒必停,返回窗口立即停止;可在偏好中关闭该提示。
60
60
  - **聚焦静默**:窗口聚焦时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
61
61
 
62
62
  ## webhook 推送
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.7.3",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
28
28
  // 页内通知展示期,经公共依赖 holdMs 传入
29
29
  const TOAST_MS = 6 * 1000
30
30
  const BLINK_MS = 1 * 1000
31
+ // 连环通知下标题闪烁的总时长硬顶
32
+ const BLINK_MAX_MS = 30 * 1000
31
33
  // 系统通知测试延迟:浏览器对聚焦窗口抑制系统弹窗,倒计时供用户切出窗口
32
34
  const SYSTEM_TEST_DELAY_MS = 5 * 1000
33
35
  // 显示回执等待上限:超时未回执按环境层拦截给出诊断
@@ -475,17 +477,28 @@ window.__ModuleLoader__.load({
475
477
  // 本包只保留标题闪烁通道
476
478
 
477
479
  let blinkTimer = null
480
+ let blinkStopTimer = null
478
481
  const baseTitle = () => document.title.replace(/^⏳ /, '')
479
482
 
483
+ // 连环通知(并行会话批量报错)间隔小于 TOAST_MS 时,闪烁会被逐条续命;
484
+ // 硬顶 BLINK_MAX_MS 保证任何情况下标题必停,焦点返回时由可见性同步立即清除
480
485
  function startTitleBlink() {
481
- if (blinkTimer !== null) return
486
+ if (blinkTimer !== null) {
487
+ clearTimeout(blinkStopTimer)
488
+ blinkStopTimer = setTimeout(stopTitleBlink, BLINK_MAX_MS - BLINK_MS)
489
+ return
490
+ }
482
491
  blinkTimer = setInterval(() => {
483
492
  document.title = document.title.startsWith('⏳ ') ? baseTitle() : '⏳ ' + baseTitle()
484
493
  }, BLINK_MS)
485
- setTimeout(stopTitleBlink, TOAST_MS)
494
+ blinkStopTimer = setTimeout(stopTitleBlink, TOAST_MS)
486
495
  }
487
496
 
488
497
  function stopTitleBlink() {
498
+ if (blinkStopTimer !== null) {
499
+ clearTimeout(blinkStopTimer)
500
+ blinkStopTimer = null
501
+ }
489
502
  if (blinkTimer === null) return
490
503
  clearInterval(blinkTimer)
491
504
  blinkTimer = null
@@ -586,8 +599,8 @@ window.__ModuleLoader__.load({
586
599
  if (channels.pageSound && toast) {
587
600
  playSound(resolveSound(unit.category, mergeMapping(effectiveMapping(), readPageMapping()), uploadedIds)).catch(() => {})
588
601
  }
589
- if (!channels.sound) continue
590
- playSound(sound).catch(() => {})
602
+ // 声音关闭只静音自身:system 弹窗与 blink 降级通道独立判断,不因无声 continue 连坐
603
+ if (channels.sound) playSound(sound).catch(() => {})
591
604
  if (channels.system) notifySystem(unit)
592
605
  else if (channels.blink && localGet(KEY_DEGRADE_HINT) !== '0') startTitleBlink()
593
606
  }
@@ -827,8 +840,12 @@ window.__ModuleLoader__.load({
827
840
  const onVisible = () => {
828
841
  if (document.hidden) return
829
842
  clearCurrentSessionHighlights()
843
+ stopTitleBlink()
844
+ }
845
+ const onFocus = () => {
846
+ clearCurrentSessionHighlights()
847
+ stopTitleBlink()
830
848
  }
831
- const onFocus = () => clearCurrentSessionHighlights()
832
849
  const dispose = () => {
833
850
  if (typeof window.removeEventListener === 'function') window.removeEventListener('focus', onFocus)
834
851
  document.removeEventListener('visibilitychange', onVisible)
@@ -26,8 +26,15 @@ class BrokenStorage {
26
26
  }
27
27
 
28
28
  // 以注入的 stub 加载真实 src/client.js,返回捕获的模块对象、页内通知捕获表与 window 桩。
29
- function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOverride }) {
30
- const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
29
+ // shortTimers:闪烁常量替换为毫秒级,配合真实定时器验证硬顶与重入(node:test mock.timers
30
+ // 拦截不到 new Function 沙箱里的 timer,不能用于本 loader)
31
+ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOverride, shortTimers }) {
32
+ const raw = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
33
+ const source = shortTimers
34
+ ? raw.replace('const TOAST_MS = 6 * 1000', 'const TOAST_MS = 60')
35
+ .replace('const BLINK_MS = 1 * 1000', 'const BLINK_MS = 20')
36
+ .replace('const BLINK_MAX_MS = 30 * 1000', 'const BLINK_MAX_MS = 200')
37
+ : raw
31
38
  const modules = []
32
39
  const shown = []
33
40
  const windowListeners = {}
@@ -85,6 +92,74 @@ const units = [
85
92
  { id: 'u2', category: 'error', text: '[dsh] 任务出错: t2' },
86
93
  ]
87
94
 
95
+ // 标题闪烁:错误类通知在未授权系统通知时走 blink 降级通道,焦点回返与硬顶都必须复位标题
96
+ test('Given 声音总开关关闭 When 错误通知到达 Then 降级闪烁照常触发', async () => {
97
+ // 声音关闭只应静音,不得连坐 system 弹窗与 blink 降级通道
98
+ const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
99
+ const { mod, document: doc } = loadClient({
100
+ storage: new FakeStorage({ 'turn-notify:sound': '0' }),
101
+ payload: { units: [units[1]], soundMapping: {}, version: 1 },
102
+ document: dom,
103
+ })
104
+ const { poll } = mod.__test
105
+ await poll()
106
+ await poll()
107
+ assert.ok(await scanTitleBlink(doc, 1400), '声音关闭时 blink 不应被 continue 连坐跳过')
108
+ })
109
+
110
+ test('Given 标题闪烁进行中 When 窗口重获焦点 Then 标题立即复位', async () => {
111
+ const docListeners = {}
112
+ const dom = {
113
+ hasFocus: () => false, title: 'dsh', hidden: true,
114
+ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn) },
115
+ removeEventListener: (type, fn) => { docListeners[type] = (docListeners[type] || []).filter((f) => f !== fn) },
116
+ }
117
+ const { mod, window: winStub, document: doc } = loadClient({
118
+ storage: new FakeStorage(),
119
+ payload: { units: [units[1]], soundMapping: {}, version: 1 },
120
+ document: dom,
121
+ })
122
+ const { poll } = mod.__test
123
+ await poll()
124
+ await poll()
125
+ mod.__test.start()
126
+ const focusHandlers = (winStub.listeners.focus || []).slice()
127
+ assert.ok(focusHandlers.length > 0, 'window focus 未监听')
128
+ doc.title = '⏳ dsh'
129
+ for (const fn of focusHandlers) fn()
130
+ assert.equal(doc.title, 'dsh', '焦点返回后标题应复位')
131
+ winStub['turn-notify:polling'].abort()
132
+ })
133
+
134
+ test('Given 连环错误通知重入闪烁 When 硬顶到期 Then 标题必停', async () => {
135
+ const dom = { hasFocus: () => false, title: 'dsh', hidden: true }
136
+ const { mod, document: doc } = loadClient({
137
+ storage: new FakeStorage(),
138
+ payload: { units: [{ id: 'e1', category: 'error', text: '[dsh] 任务出错: e1' }, { id: 'e2', category: 'error', text: '[dsh] 任务出错: e2' }], soundMapping: {}, version: 1 },
139
+ document: dom,
140
+ shortTimers: true,
141
+ })
142
+ const { poll } = mod.__test
143
+ await poll()
144
+ await poll()
145
+ // 同批两事件:第一条启动闪烁(单条 60ms 即停),第二条重入重排至硬顶 200ms
146
+ const sawBlink = await scanTitleBlink(doc, 150)
147
+ assert.ok(sawBlink, '重入应延长闪烁,不得按单条时长提前复位')
148
+ await sleep(200)
149
+ assert.equal(doc.title.indexOf('⏳'), -1, '硬顶到期标题必复位,不允许无限续闪')
150
+ })
151
+
152
+ async function scanTitleBlink(doc, ms) {
153
+ const deadline = Date.now() + ms
154
+ while (Date.now() < deadline) {
155
+ if (doc.title.indexOf('⏳') >= 0) return true
156
+ await sleep(5)
157
+ }
158
+ return false
159
+ }
160
+
161
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
162
+
88
163
  test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不再发声', async () => {
89
164
  const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
90
165
  const { poll, storageState } = mod.__test