dsh-notify-me 1.5.3 → 1.6.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.6.0] — 2026-10-15
6
+
7
+ ### 新增
8
+ - **设置页可选音效来源**(`soundDone` / `soundAttention`,「需要你」与「回复完成」各自独立):
9
+ - `'synth'`(默认)——原有 WebAudio 合成音,行为不变;
10
+ - `'custom'`——自选一个本地音频文件(上限 1 MiB,可试听、可移除),提醒时播放该文件;
11
+ - `'system'`——完全不播插件音效,改为把通知以 `silent: false` 发出,交由操作系统播放它自己的通知音(Windows 通知中心/macOS 通知中心),音色跟着系统通知设置走。
12
+ - 设置页新增「音效来源」卡片:两类提醒各一行来源下拉 + 「试听」,选「自定义音频文件」时展开文件选择、已选文件名与体积、「试听」「移除」。
13
+ - `window.__dshNotifyMe.debug()` 新增 `soundDone` / `soundAttention`(当前生效来源)与 `customSoundDone` / `customSoundAttention`(该类别是否已存自选文件)。
14
+
15
+ ### 变更
16
+ - **通知的 `silent` 不再硬编码为 `true`**:改为 `!systemCue(kind)`。默认两种模式仍是 `silent: true`(插件自己出声,避免与平台提示音叠成两声),只有 `'system'` 模式主动让平台出声。主开关 `sound: false` 仍会连系统通知音一起静音——关掉声音就该是安静的。
17
+ - 自选音频存在**独立的 localStorage 键**(`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`,值为 `{name, size, data}`),不塞进 `dshNotifyMe.config`:data URL 会膨胀约 1.37 倍,进配置会拖慢每次 `readConfig()`(每次提醒都会调用)。
18
+ - `resetConfig()` 与设置页「恢复默认设置」现在**一并清掉**这两个音频键,不再只清配置——否则文件会残留占用 origin 配额,而 UI 上已经没有入口能删它。
19
+ - **任何来源取不到声音都回落到内置合成音**(文件缺失、解码/自动播放被拒、超出配额),提醒不会因为音效配置坏掉而变成哑的。
20
+ - 版本号 1.5.3 → 1.6.0;`window.__dshNotifyMe.version` 与 `package.json` 同步。
21
+ - README(中英)同步:设置清单加「音效来源」,控制台配置块补 `soundDone` / `soundAttention` 与两个存储键的说明。
22
+
23
+ ### 修复
24
+ - **「系统通知音」模式下点「试听」不再静默无反应**:该模式本就没有插件可播的音(声音是随通知由系统播放的),而初版实现让试听照样走插件音效通道、在 `'system'` 分支直接返回,按钮看起来就是坏的。现在改为按该类别**发一条测试通知**——你听到的就是它,并在按钮下方写明原因;`sound` 总开关关闭时同样给一句说明而不是静默。
25
+
26
+ ### 测试
27
+ - `smoke/smoke-test.cjs` 新增音效来源用例段(**此前音效路径零覆盖**——harness 的 `AudioContext` 是 `undefined`,所有既有用例都静默走了「无音频」分支):注入可捕获的 `AudioContext` 后,覆盖内置合成音的两种音型(done `659,988`、attention `880,1174,1568`)、`'system'` 不产生振荡器且通知 `silent === false`、`sound: false` 在 `'system'` 模式下重新静音、`'custom'` 播放所存 data URL 且音量跟随滑块、两类来源互不影响、文件缺失或浏览器拒绝播放(`play()` 异步拒绝)时回落内置音、`resetConfig()` 清空两个音频键。
28
+ - 同一文件新增设置页渲染用例:`bootBundle` 支持注入 `require`(此前固定返回 `{}`,设置页永不注册、无法被测试),用例断言 `settings.section` 注册成功、「音效来源」两张卡片渲染、`custom` 时才出现文件选择行并显示文件名与体积,以及**按下 `'system'` 行的「试听」确实发出一条 `silent === false` 的测试通知**(为此假 React 改为按索引跨渲染保持 hook 状态,能读到点击后的反馈文案);默认路径(不注入 react)行为不变。
29
+ - `npm test` 两套件均通过;`smoke/cordis-host-test.mjs` 用 `DSH_NODE_MODULES` 指向本机 DSH 安装后**真跑**(此前在多数机器上静默 SKIP),真 cordis 下的 `inject` 语义守卫一并复核通过。
30
+
5
31
  ## [1.5.3] — 2026-10-04
6
32
 
7
33
  ### 新增
package/README.en.md CHANGED
@@ -53,7 +53,8 @@ Open **Settings → Notify me** (refresh the page once after installing):
53
53
 
54
54
  - **Enable reminders** — master switch; when off, no toasts, no sounds and the tab title is never touched;
55
55
  - **Environment** (auto-detect by default) — identifies Web vs Desktop (DSH Desktop, `dsh-app://`) from the page protocol, with a manual override for hosts where detection comes out wrong: on the Desktop a toast click rides the `dsh://` deep link to raise the window and quick-decision buttons are unavailable; on the Web it is `window.focus()` plus the Service Worker bridge. Whenever the override disagrees with detection, the settings page says what detection saw;
56
- - **System notifications / Sound / Volume** — toast toggle, WebAudio beep toggle and a volume slider;
56
+ - **System notifications / Sound / Volume** — toast toggle, cue toggle and a volume slider;
57
+ - **Alert sound** (picked separately for "needs you" and "reply finished") — built-in cue (default) / custom audio file / system notification sound. "Custom audio file" takes one local audio file in the settings page (1 MiB limit, stored in the browser, with preview and remove); "system notification sound" plays no plugin cue at all — the toast still appears and the operating system supplies the sound, so its timbre follows your system notification settings; in that mode "Preview" sends a test alert instead, because the sound only exists as part of a toast and that is the sound you hear. Any source that has no file, cannot be read, or does not fit falls back to the built-in cue, so an alert never goes silent;
57
58
  - **"Needs you" alerts while the page is open** (default on) and **"Reply finished" alerts while the page is open** (default off);
58
59
  - **Stay quiet for the conversation on screen** (default on): a wait inside the conversation you are looking at keeps only the tab marker — no toast or sound landing on the approval card. Backgrounding the page puts that alert back at once, unless you already handled it. Waits in other background sessions are unaffected;
59
60
  - **Ignore subagent sessions** (default on): background subagent child sessions raise neither "needs you" nor "reply finished" — those are steps of the parent conversation's turn, and the parent's own alert already covers them. The subagent session you have open alerts as usual. Turn it off to alert for every session;
@@ -68,7 +69,7 @@ Open **Settings → Notify me** (refresh the page once after installing):
68
69
  ## How it alerts
69
70
 
70
71
  - **System notification** — a toast through the browser into the Windows action center / macOS Notification Center (clicking it opens the conversation it came from; approval toasts can be decided straight from the buttons)
71
- - **Sound** — WebAudio beeps (distinct patterns for "needs you" vs "done")
72
+ - **Sound** — WebAudio beeps by default (distinct patterns for "needs you" vs "done"); each kind can instead play a picked audio file, or hand the sound over to the operating system's own notification sound
72
73
  - **Tab title marker** — while something is waiting on you, the tab title is prefixed with `🔔 Action needed · …` / `🔔 需要你 · …`; while a background session is **finished but unread**, with `✅ Reply finished · …` / `✅ 回复完成 · …`, gone once you open that session (or it starts running again). Both kinds can be up together, "needs you" first; a host title change is rebased under the markers
73
74
 
74
75
  This is a **browser-layer** plugin: the DSH page must stay open (minimized or backgrounded is fine — that's exactly the "away" state it watches for).
@@ -123,15 +124,21 @@ window.__dshNotifyMe.setConfig({
123
124
  doneHiddenOnly: true, // false = also alert "reply finished" while visible
124
125
  ignoreSubagent: true, // true = never alert for background subagent sessions (the open one is exempt)
125
126
  toast: true, // system-notification toggle
126
- sound: true, // sound toggle
127
+ sound: true, // cue toggle (also governs the "system notification sound" mode)
128
+ soundDone: "synth", // "reply finished" cue source: 'synth' built-in | 'custom' picked file | 'system' OS sound
129
+ soundAttention: "synth", // "needs you" cue source, same values
127
130
  volume: 0.5, // 0..1
128
131
  autoFocus: true, // clicking a toast opens the conversation it came from
129
132
  quickActions: true // Approve / Reject buttons on approval toasts
130
133
  })
131
- window.__dshNotifyMe.resetConfig() // restore defaults
134
+ window.__dshNotifyMe.resetConfig() // restore defaults (also drops any picked audio files)
132
135
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // programmatic decision: "allowed-once" | "rejected"
133
136
  ```
134
137
 
138
+ > Picked audio files do not live in `dshNotifyMe.config`: each kind gets its own key (`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`, holding `{name, size, data}` with a data URL), because a file inflates by ~1.37x once base64-encoded and would slow down every config read. `resetConfig()` clears both keys along with the config.
139
+ >
140
+ > To see which source is live, check `soundDone` / `soundAttention` and `customSoundDone` / `customSoundAttention` in `window.__dshNotifyMe.debug()`.
141
+
135
142
  ## How it works
136
143
 
137
144
  The reminder core picks its interaction source per host generation (see the table above); the current session always comes from the client `sessions` service:
@@ -186,7 +193,8 @@ Contributors (by handle; the link points at the PR / issue they worked on):
186
193
 
187
194
  - [@AgMahone](https://github.com/AgMahone) — [#7](https://github.com/chromoany/dsh-notify-me/pull/7): the 0.1.6+ / 0.2.0 host-generation support, plus the double-chime and cordis-lookup reports and retesting;
188
195
  - [@d0ublecl1ck](https://github.com/d0ublecl1ck) — [#8](https://github.com/chromoany/dsh-notify-me/pull/8): muting subagent sessions (`ignoreSubagent`) with its tests and docs; the 1.4.0 refinement — mute only background subagents, alert as usual for the one you have open — is his suggestion from the #8 discussion, landed by the maintainer;
189
- - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9): the layer-by-layer investigation and three rounds of real-shell retesting of the desktop raise path (1.5.2) — confirming that a toast click raises the window and switches conversations reliably, and pinning down the reproduction difference ("gesture-less scripted injection is silently dropped by Chromium"), which corrected the initial "the sandbox blocks it" reading.
196
+ - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9): the layer-by-layer investigation and three rounds of real-shell retesting of the desktop raise path (1.5.2) — confirming that a toast click raises the window and switches conversations reliably, and pinning down the reproduction difference ("gesture-less scripted injection is silently dropped by Chromium"), which corrected the initial "the sandbox blocks it" reading;
197
+ - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10): the per-kind alert sound source (built-in synth / picked audio file / system notification sound, chosen independently for "needs you" and "reply done"), with its settings UI, fallback chain, tests and docs; the two refinements — a "system" preview sending a real test notification instead of doing nothing, and the fake React holding hook state by index across renders — come from the same PR.
190
198
 
191
199
  Attribution policy: external PRs are landed as maintainer commits — the original commit is not reused and no Co-authored-by trailer is added, so GitHub's contributors graph lists the maintainer only and contributors are credited above.
192
200
 
package/README.md CHANGED
@@ -53,7 +53,8 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
53
53
 
54
54
  - **启用提醒** 主开关:关闭后不再弹系统通知、不播放提示音、也不改标签页标题;
55
55
  - **运行环境**(默认自动识别):按页面协议识别 Web 端 / 桌面端(DSH Desktop,`dsh-app://`),识别不准时可手动指定——桌面端点通知走 `dsh://` 深链抬窗、快捷裁决按钮不可用;Web 端走 `window.focus()` 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果;
56
- - **系统通知 / 提示音 / 音量**:Toast、WebAudio 提示音与音量滑块;
56
+ - **系统通知 / 提示音 / 音量**:Toast、提示音开关与音量滑块;
57
+ - **音效来源**(「需要你」与「回复完成」各自独立选):内置合成音(默认)/ 自定义音频文件 / 系统通知音。选「自定义音频文件」可在设置页挑一个本地音频(上限 1 MiB,存在浏览器本地,可试听与移除);选「系统通知音」则完全不播插件音效,通知照发,声音交给操作系统(音色跟着系统通知设置走),此时「试听」会改发一条测试通知——该模式没有插件可播的音,你听到的就是它。任一来源缺文件、读不出来或存不下时自动回落到内置合成音,提醒不会因此变成哑的;
57
58
  - **页面打开时也提醒「需要你」**(默认开)与 **页面打开时也提醒「回复完成」**(默认关);
58
59
  - **当前对话不弹通知**(默认开):等待就发生在你正看着的对话里时只留标题标记,不弹通知也不响音(免得盖住审批卡片);页面切到后台立刻补发,若你已经处理掉就作废。其它后台会话不受影响;
59
60
  - **子代理会话不提醒**(默认开):后台子代理(subagent)子会话的「需要你」与「回复完成」都不提醒——它们是主对话那一轮的步骤,完成由主对话自己的提醒覆盖;你当前打开的那个子代理会话照常提醒;关掉后恢复逐会话提醒;
@@ -68,7 +69,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
68
69
  ## 提醒方式
69
70
 
70
71
  - **系统通知**:经浏览器弹出的系统通知,进 Windows 通知中心或 macOS 通知中心(点击回到提醒所属的对话;审批的通知点按钮直接裁决)
71
- - **提示音**:WebAudio 合成音(「需要你」与「完成」使用不同音型)
72
+ - **提示音**:默认是 WebAudio 合成音(「需要你」与「完成」使用不同音型);可在设置里为两类提醒分别换成自定义音频文件,或改成让操作系统播放它自己的通知音
72
73
  - **标签页标题标记**:有待处理事项时,标题前出现 `🔔 需要你 · …` / `🔔 Action needed · …`;后台会话**完成但还没看**时出现 `✅ 回复完成 · …` / `✅ Reply finished · …`,打开对应会话(或它再次开跑)即消失。两种标记可同时出现,「需要你」在前;宿主改写标题后标记基于新标题重建
73
74
 
74
75
  本插件是**浏览器层**实现:DSH 页面需保持打开(最小化/后台即可——那正是它监听的「离开」状态)。
@@ -119,15 +120,21 @@ window.__dshNotifyMe.setConfig({
119
120
  doneHiddenOnly: true, // false = 页面可见时「完成」也提醒
120
121
  ignoreSubagent: true, // true = 后台子代理(subagent)会话不提醒(当前打开的照常)
121
122
  toast: true, // 系统通知开关
122
- sound: true, // 提示音开关
123
+ sound: true, // 提示音开关(含「系统通知音」模式)
124
+ soundDone: "synth", // 「完成」音效来源:'synth' 内置 | 'custom' 自定义文件 | 'system' 系统通知音
125
+ soundAttention: "synth", // 「需要你」音效来源,取值同上
123
126
  volume: 0.5, // 音量 0~1
124
127
  autoFocus: true, // 点通知回到提醒所属的对话
125
128
  quickActions: true // 审批通知上的「同意 / 拒绝」按钮
126
129
  })
127
- window.__dshNotifyMe.resetConfig() // 恢复默认
130
+ window.__dshNotifyMe.resetConfig() // 恢复默认(同时清掉已选的自定义音效文件)
128
131
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // 程序化裁决:"allowed-once" | "rejected"
129
132
  ```
130
133
 
134
+ > 自定义音效文件不在 `dshNotifyMe.config` 里,而是按类别各存一个键(`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`,内容是 `{name, size, data}` 的 data URL)——一个文件转成 data URL 会膨胀约 1.37 倍,塞进配置会拖慢每次读取。`resetConfig()` 会连这两个键一起清掉。
135
+ >
136
+ > 想确认当前生效的来源,看 `window.__dshNotifyMe.debug()` 的 `soundDone` / `soundAttention` 与 `customSoundDone` / `customSoundAttention`。
137
+
131
138
  ## 工作原理
132
139
 
133
140
  提醒核心按宿主代际取源(见上表),当前会话一律来自客户端 `sessions` 服务:
@@ -182,7 +189,8 @@ npm pack --dry-run # 预览发布包
182
189
 
183
190
  - [@AgMahone](https://github.com/AgMahone) — [#7](https://github.com/chromoany/dsh-notify-me/pull/7):0.1.6+ / 0.2.0 宿主代际支持,以及通知双响、cordis 查找路径的报障与复测;
184
191
  - [@d0ublecl1ck](https://github.com/d0ublecl1ck) — [#8](https://github.com/chromoany/dsh-notify-me/pull/8):子代理会话静音(`ignoreSubagent`)与配套测试、文档;1.4.0 里「只静音后台子代理、当前打开的照常提醒」的口径同样出自他在 #8 讨论里的建议,落地由维护者完成;
185
- - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9):桌面端抬窗链路(1.5.2)的逐层排查与真实壳三轮复测——确认点通知抬窗 + 切会话稳定生效,并定位出「无用户手势的脚本化注入被 Chromium 静默丢弃」这一复现差异,纠正了最初「沙箱拦死」的判断。
192
+ - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9):桌面端抬窗链路(1.5.2)的逐层排查与真实壳三轮复测——确认点通知抬窗 + 切会话稳定生效,并定位出「无用户手势的脚本化注入被 Chromium 静默丢弃」这一复现差异,纠正了最初「沙箱拦死」的判断;
193
+ - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10):「音效来源」功能(内置合成音 / 自定义音频文件 / 系统通知音,「需要你」与「回复完成」各自独立选),含设置页交互、回落链与测试、文档;「系统通知音」试听改发测试通知、假 React 按索引跨渲染保持 hook 状态这两处修正同样出自该 PR。
186
194
 
187
195
  署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。
188
196
 
package/lib/client.js CHANGED
@@ -239,6 +239,15 @@
239
239
  ignoreSubagent: true,
240
240
  toast: true,
241
241
  sound: true,
242
+ // Where each alert's cue comes from (per kind, so "needs you" and "reply
243
+ // finished" stay independent):
244
+ // 'synth' — the built-in WebAudio cue (default, needs no asset)
245
+ // 'custom' — an audio file the user picked, kept in its own
246
+ // localStorage key and played through an <audio> element
247
+ // 'system' — no plugin cue at all: the toast is raised un-silenced so
248
+ // the operating system plays its own notification sound
249
+ soundDone: "synth",
250
+ soundAttention: "synth",
242
251
  volume: 0.5,
243
252
  autoFocus: true, // clicking the toast raises DSH and switches to the alerted conversation
244
253
  // Approval toasts carry 同意/拒绝 buttons and settle the request on one
@@ -272,6 +281,38 @@
272
281
  return config;
273
282
  }
274
283
 
284
+ // Picked cue files live in their own localStorage keys, never inside the
285
+ // config JSON: a data URL is ~1.37x the file size, and the config is parsed
286
+ // on every readConfig() — which happens on every alert.
287
+ var LS_SOUND = { done: "dshNotifyMe.sound.done", attention: "dshNotifyMe.sound.attention" };
288
+ // 1 MiB of source audio is ~1.37 MiB once base64-encoded; localStorage
289
+ // grants ~5 MiB per origin, and the other kind plus the config must fit too.
290
+ var SOUND_MAX_BYTES = 1048576;
291
+ function soundMode(kind) {
292
+ var v = kind === "attention" ? config.soundAttention : config.soundDone;
293
+ return (v === "custom" || v === "system") ? v : "synth";
294
+ }
295
+ // True when the platform, not this plugin, should make the noise: only then
296
+ // is the toast raised un-silenced. The master Sound switch still mutes it.
297
+ function systemCue(kind) { return config.sound && soundMode(kind) === "system"; }
298
+ function readSound(kind) {
299
+ try {
300
+ var raw = localStorage.getItem(LS_SOUND[kind]);
301
+ if (!raw) return null;
302
+ var rec = JSON.parse(raw);
303
+ return (rec && typeof rec.data === "string" && rec.data) ? rec : null;
304
+ } catch (e) { return null; }
305
+ }
306
+ // false = the record did not fit (quota) or storage is unavailable; the
307
+ // caller reports it and the previous record stays in place.
308
+ function writeSound(kind, rec) {
309
+ try {
310
+ if (rec) localStorage.setItem(LS_SOUND[kind], JSON.stringify(rec));
311
+ else localStorage.removeItem(LS_SOUND[kind]);
312
+ return true;
313
+ } catch (e) { return false; }
314
+ }
315
+
275
316
  // ───────────────────────── copy tables ─────────────────────────
276
317
  // Notification copy, keyed by language id. Selected through
277
318
  // effectiveLang() so a pinned language stays pinned even while the DSH UI
@@ -641,8 +682,10 @@
641
682
  body: body || "",
642
683
  tag: tag,
643
684
  renotify: true,
644
- // The plugin owns the audio (see showToast's silent note below).
645
- silent: true,
685
+ // The plugin owns the audio (see showToast's silent note below),
686
+ // except in the 'system' cue mode, which hands the sound back to
687
+ // the platform on purpose.
688
+ silent: !systemCue(kind),
646
689
  icon: (location.origin || "") + "/favicon.svg",
647
690
  data: { key: quick.key, sessionId: sessionId || null, kind: kind, test: !!quick.test, focus: config.autoFocus !== false },
648
691
  actions: [
@@ -663,8 +706,10 @@
663
706
  // The plugin owns the audio: its WebAudio cues already differ per
664
707
  // kind (attention vs done) and answer the Sound toggle and volume
665
708
  // slider. Without this flag the platform plays its own chime on top
666
- // of the beep — two sounds stacked on one alert.
667
- silent: true,
709
+ // of the beep — two sounds stacked on one alert. The single exception
710
+ // is the 'system' cue mode, which is precisely "let the platform play
711
+ // it"; the master Sound switch still mutes that too.
712
+ silent: !systemCue(kind),
668
713
  icon: (location.origin || "") + "/favicon.svg"
669
714
  });
670
715
  if (config.autoFocus) {
@@ -705,18 +750,52 @@
705
750
  osc.stop(t0 + durMs / 1000 + 0.05);
706
751
  } catch (e) {}
707
752
  }
708
- function playAttentionSound() {
753
+ // 'custom' plays the file the user picked. Anything short of a started
754
+ // element — no file stored, a browser without the element, a constructor or
755
+ // play() that throws — falls through to the built-in cue, so an alert is
756
+ // never silent merely because a stored file went missing or unreadable.
757
+ // The last failure mode is different in kind: a decode or autoplay refusal
758
+ // rejects play()'s promise instead of throwing, so it gets the built-in cue
759
+ // handed to it as a fallback rather than falling through.
760
+ function playCustomSound(kind, fallback) {
761
+ var rec = readSound(kind);
762
+ if (!rec) return false;
763
+ try {
764
+ var el = new Audio(rec.data);
765
+ el.volume = Math.max(0, Math.min(1, config.volume));
766
+ var started = el.play();
767
+ if (started && typeof started.catch === "function") started.catch(fallback);
768
+ return true;
769
+ } catch (e) { return false; }
770
+ }
771
+ // 'system' returns silently on purpose: showToast raises the toast
772
+ // un-silenced for exactly this mode, so the OS plays its own notification
773
+ // sound and the plugin must not stack a second cue on top of it.
774
+ function playCue(kind, build) {
709
775
  if (!config.sound) return;
710
- unlockAudio();
711
- tone(880, 0.0, 140, config.volume);
712
- tone(1174, 0.16, 140, config.volume);
713
- tone(1568, 0.32, 200, config.volume);
776
+ var mode = soundMode(kind);
777
+ if (mode === "system") return;
778
+ function synth() {
779
+ // An async rejection can land after the user muted everything.
780
+ if (!config.sound) return;
781
+ unlockAudio();
782
+ build();
783
+ }
784
+ if (mode === "custom" && playCustomSound(kind, synth)) return;
785
+ synth();
786
+ }
787
+ function playAttentionSound() {
788
+ playCue("attention", function () {
789
+ tone(880, 0.0, 140, config.volume);
790
+ tone(1174, 0.16, 140, config.volume);
791
+ tone(1568, 0.32, 200, config.volume);
792
+ });
714
793
  }
715
794
  function playDoneSound() {
716
- if (!config.sound) return;
717
- unlockAudio();
718
- tone(659, 0.0, 150, config.volume);
719
- tone(988, 0.18, 260, config.volume);
795
+ playCue("done", function () {
796
+ tone(659, 0.0, 150, config.volume);
797
+ tone(988, 0.18, 260, config.volume);
798
+ });
720
799
  }
721
800
  function unlockOnce() {
722
801
  unlockAudio();
@@ -1488,10 +1567,17 @@
1488
1567
  });
1489
1568
  } catch (e) {}
1490
1569
  // keep in sync with package.json "version" on every release
1491
- api.version = "1.5.3";
1570
+ api.version = "1.6.0";
1492
1571
  api.setConfig = function (patch) { return setConfigLocal(patch); };
1493
1572
  api.resetConfig = function () {
1494
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
1573
+ try {
1574
+ localStorage.removeItem(LS_CONFIG);
1575
+ // The picked cue files are per-kind keys of their own; dropping the
1576
+ // config alone would leave them orphaned, still eating the origin's
1577
+ // quota with no UI left to remove them from.
1578
+ localStorage.removeItem(LS_SOUND.done);
1579
+ localStorage.removeItem(LS_SOUND.attention);
1580
+ } catch (e) {}
1495
1581
  config = readConfig();
1496
1582
  return config;
1497
1583
  };
@@ -1524,6 +1610,10 @@
1524
1610
  ignoreSubagent: config.ignoreSubagent,
1525
1611
  toast: config.toast,
1526
1612
  sound: config.sound,
1613
+ soundDone: soundMode("done"),
1614
+ soundAttention: soundMode("attention"),
1615
+ customSoundDone: !!readSound("done"),
1616
+ customSoundAttention: !!readSound("attention"),
1527
1617
  quickActions: config.quickActions,
1528
1618
  // quick-decision bridge: 'active' is the only state in which approval
1529
1619
  // toasts carry working 同意/拒绝 buttons.
@@ -1571,7 +1661,30 @@
1571
1661
  toast: "系统通知",
1572
1662
  toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
1573
1663
  sound: "提示音",
1574
- soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
1664
+ soundHint: "提示音总开关;具体播什么由下面的「音效来源」决定。",
1665
+ soundSource: "音效来源",
1666
+ soundSourceHint: "两类提醒各自选择声音来源,互不影响。默认是插件自带的内置合成音。",
1667
+ soundDoneLabel: "「回复完成」的声音",
1668
+ soundAttentionLabel: "「需要你」的声音",
1669
+ soundSynth: "内置合成音(默认)",
1670
+ soundCustom: "自定义音频文件",
1671
+ soundSystem: "系统通知音(系统播放)",
1672
+ soundSynthNote: "插件合成音,「需要你」与「回复完成」音型不同。",
1673
+ soundSystemNote: "不播插件音效:通知照发,声音交给操作系统——音色跟着系统通知设置走,开关仍听这里的「提示音」。",
1674
+ soundNone: "尚未选择文件",
1675
+ soundPick: "选择音频文件",
1676
+ soundPreview: "试听",
1677
+ soundClear: "移除",
1678
+ soundTooBig: "文件太大,上限 ",
1679
+ soundNotAudio: "这不是浏览器能播放的音频文件。",
1680
+ soundSaved: "已保存自定义音效:",
1681
+ soundCleared: "已移除自定义音效,该提醒回落到内置合成音。",
1682
+ soundReadFail: "读取文件失败。",
1683
+ soundQuota: "浏览器存储空间不足,存不下这个文件——先移除另一类提醒的自定义音效,或换一个更小的文件。",
1684
+ soundPreviewDone: "试听「回复完成」音效",
1685
+ soundPreviewAttention: "试听「需要你」音效",
1686
+ soundOffHint: "「提示音」开关是关的:先打开它才能试听(关掉时提醒本身也确实不出声)。",
1687
+ soundPreviewSystem: "「系统通知音」是随通知由系统播放的,插件这边没有可播的音;已发一条测试提醒,声音就是它。",
1575
1688
  attentionVisible: "页面打开时也提醒「需要你」",
1576
1689
  attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
1577
1690
  currentQuiet: "当前对话不弹通知",
@@ -1625,7 +1738,30 @@
1625
1738
  toast: "System notifications",
1626
1739
  toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
1627
1740
  sound: "Sound",
1628
- soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
1741
+ soundHint: "Master cue switch; what it actually plays comes from \"Alert sound\" below.",
1742
+ soundSource: "Alert sound",
1743
+ soundSourceHint: "Each alert kind picks its own sound source; the two never affect each other. The default is the plugin's built-in cue.",
1744
+ soundDoneLabel: "\"Reply finished\" sound",
1745
+ soundAttentionLabel: "\"Needs you\" sound",
1746
+ soundSynth: "Built-in cue (default)",
1747
+ soundCustom: "Custom audio file",
1748
+ soundSystem: "System notification sound (OS plays it)",
1749
+ soundSynthNote: "The plugin's synthesized cue — \"needs you\" and \"reply finished\" use different patterns.",
1750
+ soundSystemNote: "No plugin cue: the toast still appears, but the sound is left to the operating system, so its timbre follows your system notification settings. The Sound switch above still mutes it.",
1751
+ soundNone: "No file chosen yet",
1752
+ soundPick: "Choose an audio file",
1753
+ soundPreview: "Preview",
1754
+ soundClear: "Remove",
1755
+ soundTooBig: "File is too large; the limit is ",
1756
+ soundNotAudio: "That is not an audio file this browser can play.",
1757
+ soundSaved: "Custom sound saved: ",
1758
+ soundCleared: "Custom sound removed; this alert falls back to the built-in cue.",
1759
+ soundReadFail: "Could not read the file.",
1760
+ soundQuota: "Not enough browser storage for this file — remove the other kind's custom sound first, or pick a smaller file.",
1761
+ soundPreviewDone: "Preview the \"reply finished\" sound",
1762
+ soundPreviewAttention: "Preview the \"needs you\" sound",
1763
+ soundOffHint: "The Sound switch is off: turn it on to preview (with it off the alerts themselves stay silent too).",
1764
+ soundPreviewSystem: "The system sound plays with a toast, not from the plugin — a test alert was sent instead; that sound is it.",
1629
1765
  attentionVisible: "Also alert \"needs you\" while the page is open",
1630
1766
  attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
1631
1767
  currentQuiet: "Stay quiet for the conversation on screen",
@@ -1689,6 +1825,7 @@
1689
1825
  ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
1690
1826
  ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
1691
1827
  ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
1828
+ ".dnm-file{font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));max-width:200px}" +
1692
1829
  ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
1693
1830
  ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
1694
1831
  (function injectStyle() {
@@ -1734,9 +1871,137 @@
1734
1871
  var feedbackState = React.useState("");
1735
1872
  var feedback = feedbackState[0];
1736
1873
  var setFeedback = feedbackState[1];
1874
+ // Picked cue files bypass the config object (own localStorage keys), so
1875
+ // the page needs its own nudge to re-read them after a change.
1876
+ var soundBumpState = React.useState(0);
1877
+ var soundBump = soundBumpState[0];
1878
+ var setSoundBump = soundBumpState[1];
1737
1879
  function patch(p) {
1738
1880
  setCfg(setConfigLocal(p));
1739
1881
  }
1882
+ function previewSound(kind) {
1883
+ // The master switch is authoritative here too, so a preview with sound
1884
+ // off has to say why it stayed quiet instead of looking broken.
1885
+ if (!config.sound) { setFeedback(t("soundOffHint")); return; }
1886
+ // 'system' has no cue to play from here: the sound only exists as part
1887
+ // of a toast, which the OS then chimes for. The honest preview is
1888
+ // therefore a real test alert — otherwise the button is silent and
1889
+ // reads as broken.
1890
+ if (soundMode(kind) === "system") {
1891
+ unlockOnce();
1892
+ testAlert(kind);
1893
+ setFeedback(t("soundPreviewSystem"));
1894
+ return;
1895
+ }
1896
+ // Otherwise: the same cue the alert itself will play, volume included.
1897
+ unlockOnce();
1898
+ if (kind === "attention") playAttentionSound();
1899
+ else playDoneSound();
1900
+ }
1901
+ // One row per alert kind. The source select is always there; the file
1902
+ // picker only appears for 'custom', so the page stays short for anyone
1903
+ // who never touches it.
1904
+ function soundRows(kind, labelText) {
1905
+ var modeKey = kind === "attention" ? "soundAttention" : "soundDone";
1906
+ var previewLabel = t(kind === "attention" ? "soundPreviewAttention" : "soundPreviewDone");
1907
+ var mode = soundMode(kind);
1908
+ var rec = readSound(kind);
1909
+ // The hint states what will actually be heard, so a stored file is
1910
+ // named right where the choice is made.
1911
+ var note = mode === "system" ? t("soundSystemNote")
1912
+ : mode === "custom" ? (rec ? rec.name : t("soundNone"))
1913
+ : t("soundSynthNote");
1914
+ var rows = [
1915
+ h("div", { className: "dnm-row" }, [
1916
+ h("div", { className: "dnm-rowText" }, [
1917
+ h("div", { className: "dnm-rowLabel" }, labelText),
1918
+ h("div", { className: "dnm-rowHint" }, note)
1919
+ ]),
1920
+ h("div", { className: "dnm-btnRow" }, [
1921
+ h("select", {
1922
+ className: "dnm-select",
1923
+ value: mode,
1924
+ "aria-label": labelText,
1925
+ onChange: function (e) {
1926
+ var p = {};
1927
+ p[modeKey] = e.currentTarget.value;
1928
+ patch(p);
1929
+ }
1930
+ }, [
1931
+ h("option", { value: "synth" }, t("soundSynth")),
1932
+ h("option", { value: "custom" }, t("soundCustom")),
1933
+ h("option", { value: "system" }, t("soundSystem"))
1934
+ ]),
1935
+ h("button", {
1936
+ type: "button",
1937
+ className: "dnm-btn",
1938
+ disabled: !cfg.enabled,
1939
+ "aria-label": previewLabel,
1940
+ onClick: function () { previewSound(kind); }
1941
+ }, t("soundPreview"))
1942
+ ])
1943
+ ])
1944
+ ];
1945
+ if (mode !== "custom") return rows;
1946
+ rows.push(h("div", { className: "dnm-row" }, [
1947
+ h("div", { className: "dnm-rowText" }, [
1948
+ h("div", { className: "dnm-rowLabel" }, t("soundPick")),
1949
+ h("div", { className: "dnm-rowHint" }, (rec ? Math.round((rec.size || 0) / 1024) + " KB · " : t("soundNone") + " · ")
1950
+ + "≤" + Math.round(SOUND_MAX_BYTES / 1024) + " KB")
1951
+ ]),
1952
+ h("div", { className: "dnm-btnRow" }, [
1953
+ h("input", {
1954
+ type: "file",
1955
+ accept: "audio/*",
1956
+ className: "dnm-file",
1957
+ "aria-label": t("soundPick"),
1958
+ onChange: function (e) {
1959
+ var input = e.currentTarget;
1960
+ var f = input.files && input.files[0];
1961
+ if (!f) return;
1962
+ if (f.size > SOUND_MAX_BYTES) {
1963
+ // Clear on every exit so re-picking the same file still fires.
1964
+ input.value = "";
1965
+ setFeedback(t("soundTooBig") + Math.round(SOUND_MAX_BYTES / 1024) + " KB");
1966
+ return;
1967
+ }
1968
+ var fr = new FileReader();
1969
+ fr.onload = function () {
1970
+ input.value = "";
1971
+ var data = String(fr.result || "");
1972
+ if (data.indexOf("data:audio/") !== 0) { setFeedback(t("soundNotAudio")); return; }
1973
+ if (!writeSound(kind, { name: f.name, size: f.size, data: data })) {
1974
+ setFeedback(t("soundQuota"));
1975
+ return;
1976
+ }
1977
+ setSoundBump(soundBump + 1);
1978
+ setFeedback(t("soundSaved") + f.name);
1979
+ };
1980
+ fr.onerror = function () { input.value = ""; setFeedback(t("soundReadFail")); };
1981
+ fr.readAsDataURL(f);
1982
+ }
1983
+ }),
1984
+ h("button", {
1985
+ type: "button",
1986
+ className: "dnm-btn",
1987
+ disabled: !rec,
1988
+ "aria-label": previewLabel,
1989
+ onClick: function () { previewSound(kind); }
1990
+ }, t("soundPreview")),
1991
+ h("button", {
1992
+ type: "button",
1993
+ className: "dnm-btn",
1994
+ disabled: !rec,
1995
+ onClick: function () {
1996
+ writeSound(kind, null);
1997
+ setSoundBump(soundBump + 1);
1998
+ setFeedback(t("soundCleared"));
1999
+ }
2000
+ }, t("soundClear"))
2001
+ ])
2002
+ ]));
2003
+ return rows;
2004
+ }
1740
2005
  function runTest(kind) {
1741
2006
  if (!config.enabled) { setFeedback(t("offHint")); return; }
1742
2007
  unlockOnce();
@@ -1812,6 +2077,20 @@
1812
2077
  })
1813
2078
  ])
1814
2079
  ]),
2080
+ // Alert sound source: the built-in cue, a picked file, or the platform's
2081
+ // own notification sound. Kept next to the Sound/volume card so the
2082
+ // sound-related knobs read together, and re-read through soundBump
2083
+ // because picked files live outside the config object.
2084
+ card([].concat(
2085
+ [h("div", { className: "dnm-row" }, [
2086
+ h("div", { className: "dnm-rowText" }, [
2087
+ h("div", { className: "dnm-rowLabel" }, t("soundSource")),
2088
+ h("div", { className: "dnm-rowHint" }, t("soundSourceHint"))
2089
+ ])
2090
+ ])],
2091
+ soundRows("done", t("soundDoneLabel")),
2092
+ soundRows("attention", t("soundAttentionLabel"))
2093
+ )),
1815
2094
  card([
1816
2095
  h("div", { className: "dnm-row" }, [
1817
2096
  h("div", { className: "dnm-rowText" }, [
@@ -1854,10 +2133,11 @@
1854
2133
  h("div", { className: "dnm-rowHint" }, " ")
1855
2134
  ]),
1856
2135
  h("button", { type: "button", className: "dnm-btn", onClick: function () {
1857
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
1858
- config = readConfig();
2136
+ // api.resetConfig() also drops the picked cue files, which are
2137
+ // stored outside the config.
2138
+ setCfg(api.resetConfig());
1859
2139
  applyTitle();
1860
- setCfg(config);
2140
+ setSoundBump(soundBump + 1);
1861
2141
  setFeedback(t("resetDone"));
1862
2142
  } }, t("reset"))
1863
2143
  ]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;权限审批的通知上可直接点「同意/拒绝」完成裁决;可在设置页开关并切换通知语言。Desktop notification & message alerts for DeepSeek Harness web — toasts, sounds and a tab-title marker when the agent needs your input (approval / plan review / question) or a reply finishes in the background, Approve/Reject buttons on approval toasts to decide right from the notification; toggleable from Settings with zh/en notification language.",
5
5
  "license": "MIT",
6
6
  "type": "module",