@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 +2 -2
- package/package.json +1 -1
- package/src/client.js +22 -5
- package/test/flow.test.mjs +77 -2
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
|
-
-
|
|
59
|
+
- **降级标题闪烁**:系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;单条闪烁 6 秒自停,连环通知逐条续命但总时长硬顶 30 秒必停,返回窗口立即停止;可在偏好中关闭该提示。
|
|
60
60
|
- **聚焦静默**:窗口聚焦时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
|
|
61
61
|
|
|
62
62
|
## webhook 推送
|
package/package.json
CHANGED
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)
|
|
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
|
-
|
|
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)
|
package/test/flow.test.mjs
CHANGED
|
@@ -26,8 +26,15 @@ class BrokenStorage {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// 以注入的 stub 加载真实 src/client.js,返回捕获的模块对象、页内通知捕获表与 window 桩。
|
|
29
|
-
|
|
30
|
-
|
|
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
|