@mzzsfy/dsh-turn-notify 0.7.2 → 0.7.3

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
@@ -73,7 +73,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
73
73
 
74
74
  ## 多窗口只响一次
75
75
 
76
- 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。
76
+ 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮仅静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现)。
77
77
 
78
78
  ## 设置面板指南
79
79
 
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.2",
4
+ "version": "0.7.3",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -537,6 +537,9 @@ window.__ModuleLoader__.load({
537
537
  }
538
538
  // 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
539
539
  if (typeof payload.version !== 'number') return false
540
+ // 冷启动对齐:页面关闭/刷新期间错过的存量通知只补游标与完成标记,不轰炸呈现;
541
+ // 错过即过期的投影设计下,重开逐条回放只剩连环提示与声音,信息价值为零
542
+ const coldStart = projectionCursor === null
540
543
  projectionCursor = payload.version
541
544
  soundMapping = payload.soundMapping || {}
542
545
  sessionHighlightEnabled = readSessionHighlightEnabled()
@@ -559,6 +562,12 @@ window.__ModuleLoader__.load({
559
562
  storageState.broken = true
560
563
  }
561
564
  for (const unit of units) {
565
+ // 冷启动对齐:错过窗口期的存量通知不回放轰炸;审批与提问例外——
566
+ // 它们在等用户动作,静默会把待办吞掉
567
+ if (coldStart && unit.category !== 'approval' && unit.category !== 'ask') {
568
+ try { markDone(unit.id) } catch { /* 存储不可用:标记缺失由内存去重兜底,冷启动本就不发声 */ }
569
+ continue
570
+ }
562
571
  if (!claimEvent(unit.id)) continue
563
572
  markDone(unit.id)
564
573
  const channels = chooseChannels(document.hasFocus(), notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category)
@@ -57,12 +57,16 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
57
57
  }
58
58
  const defaultFetch = async (path) => {
59
59
  if (onFetch) onFetch(path)
60
+ // 首拉返回空投影:真实客户端启动即冷启动对齐,既有"正常消费"语义从第二次轮询起成立
61
+ calls += 1
62
+ if (calls === 1) return { ok: true, json: async () => ({ units: [], soundMapping: {}, version: 0 }) }
60
63
  return { ok: true, json: async () => payload }
61
64
  }
62
65
  const factory = new Function(
63
66
  'window', 'require', 'document', 'MutationObserver', 'fetch', 'Notification',
64
67
  source + '\n;return null',
65
68
  )
69
+ let calls = 0
66
70
  factory(
67
71
  windowStub,
68
72
  requireStub,
@@ -85,10 +89,12 @@ test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不
85
89
  const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
86
90
  const { poll, storageState } = mod.__test
87
91
  await poll()
92
+ await poll()
88
93
  assert.equal(storageState.broken, true)
89
94
  // 两事件各发声一次,清理段未抛出控制流到达 claimEvent
90
95
  assert.equal(shown.length, units.length)
91
96
  await poll()
97
+ await poll()
92
98
  // 投影窗口内第二轮 poll 同事件不再发声
93
99
  assert.equal(shown.length, units.length)
94
100
  })
@@ -98,11 +104,13 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
98
104
  const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
99
105
  const { poll } = mod.__test
100
106
  await poll()
107
+ await poll()
101
108
  assert.equal(shown.length, units.length)
102
109
  assert.equal(storage.getItem('turn-notify:lock:stale'), null)
103
110
  assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
104
111
  assert.notEqual(storage.getItem('turn-notify:done:u2'), null)
105
112
  await poll()
113
+ await poll()
106
114
  // 完成标记生效,第二轮不重复发声
107
115
  assert.equal(shown.length, units.length)
108
116
  })
@@ -110,10 +118,55 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
110
118
  test('页内通知经公共组件:文案与展示期随事件传入', async () => {
111
119
  const { mod, shown } = loadClient({ storage: new FakeStorage(), payload: { units, soundMapping: {}, version: 1 } })
112
120
  await mod.__test.poll()
121
+ await mod.__test.poll()
113
122
  assert.deepEqual(shown.map((call) => call.text), units.map((unit) => unit.text))
114
123
  assert.ok(shown.every((call) => call.opts && call.opts.holdMs === 6 * 1000))
115
124
  })
116
125
 
126
+ test('Given 冷启动首拉带存量通知 When poll Then 静默对齐不呈现且完成标记写入', async () => {
127
+ const storage = new FakeStorage()
128
+ const { mod, shown } = loadClient({
129
+ storage,
130
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units, soundMapping: {}, version: 1 }) }),
131
+ })
132
+ await mod.__test.poll()
133
+ await mod.__test.poll()
134
+ assert.equal(shown.length, 0, '冷启动对齐不应轰炸呈现')
135
+ assert.notEqual(storage.getItem('turn-notify:done:u1'), null, '静默对齐仍需写完成标记防其他窗口回放')
136
+ })
137
+
138
+ test('Given 冷启动存量含审批与提问 When poll Then 照常呈现待办', async () => {
139
+ const todoUnits = [
140
+ { id: 'u-ap', category: 'approval', text: '[dsh] 等待审批: t1' },
141
+ { id: 'u-ask', category: 'ask', text: '[dsh] AI 提问: t2' },
142
+ ]
143
+ const { mod, shown } = loadClient({
144
+ storage: new FakeStorage(),
145
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: todoUnits, soundMapping: {}, version: 1 }) }),
146
+ })
147
+ await mod.__test.poll()
148
+ await mod.__test.poll()
149
+ assert.deepEqual(shown.map((call) => call.text), todoUnits.map((unit) => unit.text), '等待用户动作的通知不因冷启动静默')
150
+ })
151
+
152
+ test('Given 冷启动已对齐 When 新通知到达 Then 正常呈现', async () => {
153
+ let version = 1
154
+ let payloadUnits = units
155
+ const storage = new FakeStorage()
156
+ const { mod, shown } = loadClient({
157
+ storage,
158
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: payloadUnits, soundMapping: {}, version }) }),
159
+ })
160
+ await mod.__test.poll()
161
+ await mod.__test.poll()
162
+ assert.equal(shown.length, 0)
163
+ version = 2
164
+ payloadUnits = [...units, { id: 'u3', category: 'completed', text: '[dsh] 任务完成: t3' }]
165
+ await mod.__test.poll()
166
+ await mod.__test.poll()
167
+ assert.deepEqual(shown.map((call) => call.text), ['[dsh] 任务完成: t3'])
168
+ })
169
+
117
170
  // ---- 会话行高亮:认领后按投影 session 字段在侧边栏定位行并挂类 ----
118
171
 
119
172
  // 最小侧边栏 DOM:列表容器(多标题)→ 多会话行(各单标题),classList 记录变更,click listener 捕获;
@@ -174,6 +227,7 @@ test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类',
174
227
  document: { title: '会话乙 — DeepSeek Harness', ...dom },
175
228
  })
176
229
  await mod.__test.poll()
230
+ await mod.__test.poll()
177
231
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '会话行未挂高亮类')
178
232
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '高亮类缺少分类色')
179
233
  })
@@ -189,6 +243,7 @@ test('Given 通知的是当前查看的会话 When 认领 Then 不挂高亮', as
189
243
  document: { title: '会话甲 — DeepSeek Harness', ...dom },
190
244
  })
191
245
  await mod.__test.poll()
246
+ await mod.__test.poll()
192
247
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '当前查看的会话不应闪烁')
193
248
  })
194
249
 
@@ -203,6 +258,7 @@ test('Given 当前会话判定不受标题闪烁前缀干扰 When 认领 Then
203
258
  document: { title: '⏳ 会话甲 — DeepSeek Harness', ...dom },
204
259
  })
205
260
  await mod.__test.poll()
261
+ await mod.__test.poll()
206
262
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '闪烁前缀不应破坏当前会话判定')
207
263
  })
208
264
 
@@ -217,6 +273,7 @@ test('Given 失焦且通知的是本标签页会话 When 认领 Then 挂高亮',
217
273
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
218
274
  })
219
275
  await mod.__test.poll()
276
+ await mod.__test.poll()
220
277
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '失焦期间的本会话通知应闪烁提醒')
221
278
  })
222
279
 
@@ -232,6 +289,7 @@ test('Given 失焦期间挂上的当前会话高亮 When 页面重新可见 Then
232
289
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
233
290
  })
234
291
  await mod.__test.poll()
292
+ await mod.__test.poll()
235
293
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
236
294
  assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true)
237
295
  mod.__test.start()
@@ -257,6 +315,7 @@ test('Given 失焦挂上的当前会话高亮 When 窗口重获焦点 Then 提
257
315
  document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
258
316
  })
259
317
  await mod.__test.poll()
318
+ await mod.__test.poll()
260
319
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
261
320
  mod.__test.start()
262
321
  const focusHandlers = (winStub.listeners.focus || []).slice()
@@ -278,6 +337,7 @@ test('Given 行处于运行状态 When 通知认领 Then 不挂高亮', async ()
278
337
  document: dom,
279
338
  })
280
339
  await mod.__test.poll()
340
+ await mod.__test.poll()
281
341
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '运行中的会话不应闪烁')
282
342
  })
283
343
 
@@ -292,10 +352,12 @@ test('Given 已挂高亮 When 行进入运行状态 Then 清除闪烁', async ()
292
352
  document: dom,
293
353
  })
294
354
  await mod.__test.poll()
355
+ await mod.__test.poll()
295
356
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
296
357
  // 会话又开跑:状态点文案回到行内
297
358
  dom.rows[0].textContent = '进行中会话甲 5分钟'
298
359
  await mod.__test.poll()
360
+ await mod.__test.poll()
299
361
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '会话运行后应停止闪烁')
300
362
  })
301
363
 
@@ -310,6 +372,7 @@ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路
310
372
  document: dom,
311
373
  })
312
374
  await mod.__test.poll()
375
+ await mod.__test.poll()
313
376
  assert.equal(shown.length, 1)
314
377
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
315
378
  })
@@ -330,6 +393,7 @@ test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确
330
393
  document: dom,
331
394
  })
332
395
  await mod.__test.poll()
396
+ await mod.__test.poll()
333
397
  assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '长标题行未挂类')
334
398
  assert.ok(dom.rows[1].classList.set.has('tn-sess-hl--ask'), '短标题行未挂到自身行')
335
399
  assert.equal(dom.rows[1].classList.set.has('tn-sess-hl--completed'), false, '长标题类误挂到短标题行')
@@ -351,6 +415,7 @@ test('Given 两行各自高亮 When 点击其中一行 Then 仅该行清除且
351
415
  document: dom,
352
416
  })
353
417
  await mod.__test.poll()
418
+ await mod.__test.poll()
354
419
  mod.__test.start()
355
420
  const clickListeners = dom.listeners.click || []
356
421
  assert.ok(clickListeners.length > 0, 'click listener 未挂载')
@@ -438,6 +503,7 @@ test('页内提示通道独立开关:关闭后投影事件不再弹页内提示'
438
503
  const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
439
504
  const { poll } = mod.__test
440
505
  await poll()
506
+ await poll()
441
507
  assert.equal(shown.length, 0)
442
508
  // 完成标记已写:事件被认领消费,仅通道被关
443
509
  assert.notEqual(storage.getItem('turn-notify:done:u1'), null)