dsh-notify-ding 0.1.0 → 0.1.2

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
@@ -1,8 +1,8 @@
1
1
  # dsh·通知叮咚插件
2
2
 
3
- 在 DSH 需要你人工回答或一轮对话跑完时,弹出系统通知,并让宿主播放 Windows 自带的提示音。
3
+ 在 DSH 需要你人工回答或一轮对话跑完时,弹出系统通知。
4
4
 
5
- 纯浏览器端触发、宿主端发声的双半身插件,不替换任何原生界面。
5
+ 纯浏览器端的插件,不替换任何原生界面。
6
6
 
7
7
  ## 引言
8
8
 
@@ -13,8 +13,10 @@
13
13
 
14
14
  - 某个会话出现新的待人工回答交互时叮咚一次,question、plan-review、approval 都算。
15
15
  - 某个会话从执行中落到空闲、也就是一轮对话跑完时叮咚一次。
16
- - 两种触发都会弹浏览器系统通知,同时请宿主播放 Windows 内置提示音。
16
+ - 两种触发都会弹一条浏览器系统通知,提示音由 Windows 通知系统自带。
17
17
  - 不判断页面是否聚焦,也不区分是否为当前会话,任何会话有事都会响。
18
+ - 通知不设自动关闭时间,会一直留在系统通知里,直到对应会话发生操作:会话被选中、该会话开始新一轮、待回答被解决,或会话从列表移除。
19
+ - 每个会话最多保留一条通知,新通知会顶掉同会话的旧通知;点击通知会聚焦窗口并切到该会话。
18
20
  - 同一待回答请求只响一次,两次响铃之间至少有 800 毫秒间隔,且该间隔跨标签页共享,多开标签页不会重复响。
19
21
 
20
22
  ## 使用
@@ -23,11 +25,17 @@
23
25
 
24
26
  浏览器系统通知需要页面授权。插件在加载时会尝试请求一次,若被浏览器拒绝,会在你下一次点击或按键时再请求一次。所以在第一次看到页面后点一下任意位置,授权弹窗就会出来。
25
27
 
26
- 若浏览器通知一直弹不出来,声音仍然会正常响——两条路是独立的。
28
+ ### 提示音从哪来
27
29
 
28
- ### 声音从哪来
30
+ 插件不自行发声,提示音由 Windows 的通知系统在弹通知时自带。提示音的音量与静音都跟着系统通知设置走,和插件本身无关。
29
31
 
30
- 浏览器沙箱读不到 `C:\Windows\Media` 下的文件,所以声音必须由宿主进程来播。宿主半身在 web 载体上注册一条精确路由 `POST /dsh-notify-ding/ding`,收到请求就用 PowerShell 的 `System.Media.SoundPlayer` 播放 `Windows Notify System Generic.wav`,响一声。
32
+ ### 全屏时通知会被系统静默
33
+
34
+ Windows 11 的「专注」有一条自动规则「当我在全屏使用应用时」,全屏看视频或玩游戏时会自动进入专注状态,通知横幅不显示、通知音被静音,通知只落进通知中心,看起来就像插件根本没响。这是系统行为,插件感知不到也绕不开。
35
+
36
+ 想在全屏时也看到横幅,去 设置 → 系统 → 通知 → 专注,关闭「自动开启专注会话」里的「当我在全屏使用应用时」。代价是全屏时其他应用的通知也会冒出来。
37
+
38
+ 此时插件没有任何声音提示,因为它不自己发声,提示音就是被一并静音的通知音。
31
39
 
32
40
  ## 安装
33
41
 
@@ -57,13 +65,13 @@ dsh plugin --profile web remove dsh-notify-ding
57
65
 
58
66
  浏览器半身订阅 `ctx.sessions.list` 与 `ctx.uiSession.pendingInteractions` 两个只读快照,用边沿检测判断该不该响:运行态从真落到假算一轮跑完,出现新 key 的待回答交互算需要人工介入。首帧只建基线不发声,同一待回答请求只响一次,两次响铃之间留至少 800 毫秒并把该时刻写入 localStorage 跨标签页共享。
59
67
 
60
- 判定通过时它做两件事:弹浏览器系统通知,以及向宿主发一个 POST。宿主半身只负责后者,它注册的 `/dsh-notify-ding/ding` 路由收到请求就用 PowerShell 播放提示音。完整设计见 [设计说明](docs/design.md)。
68
+ 判定通过时它弹出一条浏览器系统通知。通知按会话记录且不设自动关闭,同一会话只留最新一条;会话被选中、开始新一轮、待回答被解决或从列表移除时会关掉对应通知,点击通知则聚焦窗口、切换过去并关掉该条。完整设计见 [设计说明](docs/design.md)。
61
69
 
62
70
  ## 要求与开发
63
71
 
64
72
  - 是标准形态的 dsh client 插件,声明 `dsh.client`,导出 `./client`。
65
73
  - 同时声明了 `dsh.bundle`,因此也是一个自挂载的 bundle 层插件:用 `dsh plugin --profile <name> add` 从 GitHub 安装后会被自动识别为 profile layer 并挂载,无需手工写组合 entry。
66
- - 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`。
74
+ - 浏览器半身依赖 `uiSession` 与 `sessions`;宿主入口没有运行时行为,只为让包能被 Loader 加载。
67
75
  - 构建型插件:`src/` 是 TypeScript 源码,`lib/` 是构建产物且不入库,安装或发布前由 `prepare` 构建。
68
76
 
69
77
  ## 开发
@@ -74,7 +82,7 @@ pnpm run typecheck # tsc --noEmit 严格类型检查
74
82
  pnpm run build # tsdown,产出 lib/index.js 与 lib/client.js
75
83
  ```
76
84
 
77
- - `src/index.ts`:宿主半身,注册播音路由。
85
+ - `src/index.ts`:宿主入口,无运行时行为,只用于让包被 Loader 加载。
78
86
  - `src/client/index.ts`:浏览器半身,边沿检测与通知。
79
87
  - 构建用 tsdown,client 产物是 `window.__ModuleLoader__.load({ id, factory })` 的注册式模块,`@deepseek-ai/cordis` 保持外部依赖。
80
88
 
package/cordis.patch.yml CHANGED
@@ -1,9 +1,7 @@
1
- # dsh-notify-ding bundle patch: mount the notification plugin.
2
- #
3
- # Applied when this bundle appears in a profile's dsh.profile.bundles list.
4
- # The node half (package root) owns the host-side sound route and the optional
5
- # window sound; the browser half (dsh.client) is discovered automatically
6
- # through the package's dsh.client declaration + exports["./client"].
1
+ # dsh-notify-ding bundle patch:把通知插件插进 profile 的 layer stack。
2
+ # 当本 bundle 出现在 profile 的 dsh.profile.bundles 列表里时应用。
3
+ # 宿主半身没有运行时行为,只让包能被 Loader 加载。
4
+ # 浏览器半身由包的 dsh.client 声明与 exports["./client"] 自动发现。
7
5
 
8
6
  - insert:
9
7
  - id: dsh-notify-ding
package/docs/design.md CHANGED
@@ -1,13 +1,10 @@
1
1
  # 设计说明
2
2
 
3
- ## 为什么是双半身
3
+ ## 为什么只剩浏览器半身
4
4
 
5
- 浏览器沙箱读不到 `C:\Windows\Media` 下的音频文件,所以「Windows 本家音色」这件事只能由宿主进程完成。插件因此拆成两半:
5
+ 插件原先让宿主进程用 PowerShell 播放 `C:\Windows\Media` 下的 wav。实测下来,实际听到的提示音是系统通知自带的音效,宿主那一侧没有可感知的效果,因此整条宿主播音链路已删除,插件只保留浏览器半身。
6
6
 
7
- - **宿主半身** 只负责发声:在 web 载体上注册一条精确路由,收到请求就用 PowerShell 的 `System.Media.SoundPlayer` 播放 wav。
8
- - **浏览器半身** 只负责判断:盯住会话状态,判断什么时刻该响,然后同时弹系统通知并请求宿主播音。
9
-
10
- 宿主半身不参与任何触发判断,浏览器半身不碰任何文件路径,职责边界清楚。
7
+ 宿主入口文件仍然存在,但没有任何运行时行为。客户端模块系统按 Loader entry 扫描 `dsh.client` 声明,entry 指向的宿主模块必须存在,浏览器半身才会被加载,因此 `src/index.ts` 只导出插件名和一个空的 `apply`。
11
8
 
12
9
  ## 触发信号
13
10
 
@@ -28,17 +25,30 @@
28
25
  2. **同一待回答请求只响一次**:按交互的稳定 key 记忆,消失后清理记录但不发声。
29
26
  3. **滚动最小间隔 800 毫秒**:两次响铃之间至少间隔这么久。窗口内到达的请求不丢弃,而是挂到窗口边界的定时器上补发,保证不漏报。该时刻写入 localStorage 跨标签页共享,多开标签页时同一件事只响一轮。
30
27
 
31
- ## 声音与通知的关系
28
+ ## 通知的存活与关闭
29
+
30
+ 浏览器系统通知不再设自动关闭时间,并且带上 `requireInteraction`,会一直留在系统通知里,直到对应会话发生操作。插件按会话 id 记录当前挂着的通知,每个会话最多一条,新的先关掉旧的。
31
+
32
+ 以下任一情况出现,就算对应会话得到了一次操作,插件会关掉它的通知:
33
+
34
+ 1. **会话被选中**:`sessions.list.current` 变成了该会话。
35
+ 2. **开始新一轮**:该会话的运行态从假升到真,说明用户回到了这里。
36
+ 3. **待回答被解决**:该会话的待回答交互 key 消失;被新 key 顶掉也算,替换场景下先关旧通知再响新的一条,不会误关。
37
+ 4. **会话被移除**:该会话从列表消失。
38
+
39
+ 选中信号有两处细节。`current` 短暂变成 `undefined` 是 DSH 的 masked gap,选中会话暂时不在列表时就会出现,不算一次操作,也不覆盖上一个有效选中。选中变化先于响铃处理,因此同一帧里既跑完又刚被选中的会话,通知不会被建立后立刻关掉。
40
+
41
+ 点击通知会先聚焦窗口,再经 `ctx.sessions.open(sessionId)` 切到该会话,然后关掉这条通知;会话已不在列表时只聚焦、不切换。插件卸载时会把还挂着的通知全部关掉。
32
42
 
33
- 两者互相独立:浏览器通知弹不出来时声音照响,宿主播音失败时通知照弹。任一侧失败只记一条 `console.warn`,不抛错、不互相阻塞。
43
+ 只识别这四种状态信号,因此通知只有在 DSH 里真的对那个会话动了手才会消失;单纯回到浏览器窗口不会关掉它。
34
44
 
35
- ## 认证
45
+ ## 全屏静默
36
46
 
37
- dsh web 由 connection 插件提供认证边界:根路径带 token 打开后铸出浏览器 cookie,后续同源请求自动带上。插件走同源 fetch 且带 `credentials: 'same-origin'`,因此沿用同一套 cookie,不需要自己处理 token。
47
+ Windows 11 的「专注」有一条自动规则「当我在全屏使用应用时」,全屏看视频或玩游戏时会自动进入专注状态,通知横幅不显示、通知音被静音,通知只落进通知中心。插件不自己发声,提示音就是系统通知自带的音效,所以这种场合没有任何声音提示。
38
48
 
39
49
  ## 已知边界
40
50
 
41
- - 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`,缺任一则不激活。
42
- - 播音走 PowerShell 进程,单次开销约是一百毫秒级,慢但不阻塞响应。
51
+ - 浏览器半身依赖 `uiSession` 与 `sessions`,缺任一则不激活。
43
52
  - 「一轮跑完」只覆盖正常结束与被打断这两种空闲落点,插件不区分具体结束原因。
44
- - Windows 聚焦助手开启时系统通知可能被静音,但宿主播音不受影响。
53
+ - 系统通知被静默时横幅和声音一起消失,插件察觉不到,也没有补偿手段。
54
+ - 页面被关掉后,浏览器通知可能仍留在系统通知中心,此时插件已不在运行,无法再收回,只能手动清掉。
package/lib/client.js CHANGED
@@ -8,18 +8,42 @@ window.__ModuleLoader__.load({
8
8
  [Symbol.toStringTag]: { value: "Module" }
9
9
  });
10
10
  //#region src/client/index.ts
11
+ /**
12
+ * dsh-notify-ding 的浏览器半身。
13
+ *
14
+ * 只做两件事:判断「该响了」,然后弹一条浏览器系统通知。
15
+ * 提示音由系统通知自带,插件不另外发声,全屏专注时通知音会被系统一并静音。
16
+ *
17
+ * 触发条件有两个:
18
+ * 1. 某个会话出现了新的待人工回答交互,question / plan-review / approval 都算;
19
+ * 2. 某个会话从「执行中」落到「空闲」,即一轮对话跑完。
20
+ *
21
+ * 按用户选择,两种都响,且不判断页面是否聚焦、不区分是否为当前会话。
22
+ * 重复靠两道闸门挡:同一待回答请求的 key 只响一次,以及跨标签页共享
23
+ * 的最小间隔,避免多开标签页时同一件事响好几轮。
24
+ *
25
+ * 通知不设自动关闭时间,会一直挂在系统通知里,直到对应会话发生操作:
26
+ * 会话被选中、该会话开始新一轮、待回答被解决,或会话从列表移除。每个会话
27
+ * 最多只保留最新一条,新通知会顶掉旧的;点击通知会聚焦窗口并切到该会话。
28
+ *
29
+ * @module dsh-notify-ding/client
30
+ */
11
31
  /** 插件名,同时也是配置项 id。 */
12
32
  const name = "dsh-notify-ding";
13
33
  /** 本插件需要的客户端服务。 */
14
34
  const inject = ["uiSession", "sessions"];
15
- /** 宿主播音路由,与宿主半身注册的路径一致。 */
16
- const DING_PATH = "/dsh-notify-ding/ding";
17
35
  /** 两次响铃之间的最小间隔,窗口内的合并成一次。 */
18
36
  const MIN_INTERVAL_MS = 800;
19
37
  /** 跨标签页共享「上次响铃时刻」的 localStorage 键。 */
20
38
  const LAST_DING_KEY = "dsh-notify-ding.lastDingAt";
21
- /** 系统通知自动关闭的延时。 */
22
- const NOTIFICATION_TTL_MS = 1e4;
39
+ /** 当前挂在系统通知里、按会话索引的通知,新通知会顶掉同会话的旧通知。 */
40
+ const liveNotifications = /* @__PURE__ */ new Map();
41
+ /** 打开会话的入口,由 apply 从 ctx.sessions 注入;缺失时点击通知只关通知。 */
42
+ let sessionOpener;
43
+ /** 会话列表快照源,点击通知切会话前用它确认会话还在。 */
44
+ let listSourceRef;
45
+ /** 上一帧被选中的会话,用来识别「用户选中了某个会话」这个操作信号。 */
46
+ let lastCurrent;
23
47
  /** 记录各会话上一帧状态,用来做边沿检测。 */
24
48
  const tracks = /* @__PURE__ */ new Map();
25
49
  /** 本页面上次响铃的时间戳,兜住同一帧内的重复触发。 */
@@ -54,43 +78,69 @@ window.__ModuleLoader__.load({
54
78
  console.warn("[dsh-notify-ding] 写入共享响铃时刻失败:" + String(error));
55
79
  }
56
80
  }
57
- /**
58
- * 请求宿主播放提示音。
59
- *
60
- * 声音本身与系统通知互相独立:通知弹不出来时声音照样响,反之亦然。
61
- * 这里不阻塞调用方,播音失败只记一条警告。
62
- */
63
- function requestHostDing() {
64
- fetch(DING_PATH, {
65
- method: "POST",
66
- credentials: "same-origin"
67
- }).catch((error) => {
68
- console.warn("[dsh-notify-ding] 请求宿主播音失败:" + String(error));
69
- });
81
+ /** 关掉某会话的通知;没有或已关就当无操作。 */
82
+ function closeNotification(sessionId) {
83
+ const notification = liveNotifications.get(sessionId);
84
+ if (!notification) return;
85
+ liveNotifications.delete(sessionId);
86
+ try {
87
+ notification.close();
88
+ } catch (error) {
89
+ console.warn("[dsh-notify-ding] 关闭系统通知失败:" + String(error));
90
+ }
91
+ }
92
+ /** 关掉当前挂着的全部通知,插件卸载时收尾。 */
93
+ function closeAllNotifications() {
94
+ for (const sessionId of Array.from(liveNotifications.keys())) closeNotification(sessionId);
95
+ }
96
+ /** 聚焦窗口并切到目标会话;缺少入口或会话已不在列表时只聚焦。 */
97
+ function openSessionInUi(sessionId) {
98
+ try {
99
+ window.focus();
100
+ } catch (error) {
101
+ console.warn("[dsh-notify-ding] 聚焦窗口失败:" + String(error));
102
+ }
103
+ const open = sessionOpener;
104
+ const source = listSourceRef;
105
+ if (!open || !source) return;
106
+ try {
107
+ if (source.getSnapshot().byId[sessionId] === void 0) return;
108
+ open(sessionId);
109
+ } catch (error) {
110
+ console.warn("[dsh-notify-ding] 打开会话失败:" + String(error));
111
+ }
70
112
  }
71
113
  /**
72
- * 弹一条浏览器系统通知。
114
+ * 弹一条浏览器系统通知,并挂在对应会话名下。
115
+ *
116
+ * 通知不设自动关闭时间,会一直挂在系统通知里,直到对应会话发生操作。
117
+ * 每个会话最多保留一条,新的会先关掉同会话的旧的。
118
+ * 点击通知会聚焦窗口并切到该会话,同时关掉这条通知。
73
119
  *
74
120
  * 浏览器未授权时不强行索要权限,而是记下需要补授权,等到用户下一次
75
121
  * 与页面交互时再请求——权限请求必须由用户手势触发。
76
122
  *
123
+ * @param sessionId 这条通知所属的会话。
77
124
  * @param title 通知标题。
78
125
  * @param body 通知正文。
79
126
  */
80
- function showSystemNotification(title, body) {
127
+ function showSystemNotification(sessionId, title, body) {
81
128
  if (typeof Notification === "undefined") return;
82
129
  const present = () => {
130
+ closeNotification(sessionId);
83
131
  try {
84
- const notification = new Notification(title, { body });
132
+ const notification = new Notification(title, {
133
+ body,
134
+ requireInteraction: true
135
+ });
85
136
  notification.onclick = () => {
86
- try {
87
- window.focus();
88
- } catch (error) {
89
- console.warn("[dsh-notify-ding] 聚焦窗口失败:" + String(error));
90
- }
91
- notification.close();
137
+ openSessionInUi(sessionId);
138
+ closeNotification(sessionId);
139
+ };
140
+ notification.onclose = () => {
141
+ if (liveNotifications.get(sessionId) === notification) liveNotifications.delete(sessionId);
92
142
  };
93
- window.setTimeout(() => notification.close(), NOTIFICATION_TTL_MS);
143
+ liveNotifications.set(sessionId, notification);
94
144
  } catch (error) {
95
145
  console.warn("[dsh-notify-ding] 弹出系统通知失败:" + String(error));
96
146
  }
@@ -125,34 +175,27 @@ window.__ModuleLoader__.load({
125
175
  window.addEventListener("keydown", onGesture, true);
126
176
  }
127
177
  /**
128
- * 发一次「叮咚」:弹系统通知并请宿主播音。
129
- * @param title 通知标题。
130
- * @param body 通知正文。
131
- */
132
- function emit(title, body) {
133
- showSystemNotification(title, body);
134
- requestHostDing();
135
- }
136
- /**
137
178
  * 发一次「叮咚」,并做节流。
138
179
  *
139
180
  * 节流时刻写进 localStorage,多标签页共享,避免多开时同一件事响好几轮。
140
181
  * 被节流压下的请求不会直接丢弃,而是挂到窗口边界的定时器上补发,
141
182
  * 这样连续两次状态变化仍然各响一次,只是间隔被拉开,不会出现漏报。
142
183
  *
184
+ * @param sessionId 这条通知所属的会话。
143
185
  * @param title 通知标题。
144
186
  * @param body 通知正文。
145
187
  */
146
- function ding(title, body) {
188
+ function ding(sessionId, title, body) {
147
189
  const now = Date.now();
148
190
  const since = now - Math.max(lastDingAt, readSharedLastDing());
149
191
  if (since < 0 || since >= MIN_INTERVAL_MS) {
150
192
  lastDingAt = now;
151
193
  writeSharedLastDing(now);
152
- emit(title, body);
194
+ showSystemNotification(sessionId, title, body);
153
195
  return;
154
196
  }
155
197
  deferred = {
198
+ sessionId,
156
199
  title,
157
200
  body
158
201
  };
@@ -165,7 +208,7 @@ window.__ModuleLoader__.load({
165
208
  const stamp = Date.now();
166
209
  lastDingAt = stamp;
167
210
  writeSharedLastDing(stamp);
168
- emit(pending.title, pending.body);
211
+ showSystemNotification(pending.sessionId, pending.title, pending.body);
169
212
  }, MIN_INTERVAL_MS - since);
170
213
  }
171
214
  /** 会话标题,缺失时退回会话 id,保证通知里总有个能认的名字。 */
@@ -177,10 +220,22 @@ window.__ModuleLoader__.load({
177
220
  * 用一帧会话列表快照做边沿检测:新出现的待回答交互响一次,运行态从真落到假响一次。
178
221
  *
179
222
  * 首帧只建基线不响,否则每次刷新页面都会把既有状态补报一轮。
223
+ * 同时识别「会话被操作」的信号,把对应会话还挂着的通知关掉:
224
+ * 运行态从假升到真说明已回到该会话开始新一轮,会话被选中,
225
+ * 以及会话从列表消失,都算一次操作。
226
+ *
227
+ * 选中变化先于响铃处理,否则同一帧里既跑完又刚被选中的会话,通知会建立后
228
+ * 立刻被关掉。current 短暂变空是 DSH 的 masked gap,选中会话暂时不在列表时
229
+ * 就会出现,不算一次操作,也不该把上一个有效选中抹掉。
180
230
  *
181
231
  * @param state 当前会话列表快照。
232
+ * @param baseline 是否首帧,首帧只建基线。
182
233
  */
183
234
  function scanSessions(state, baseline) {
235
+ if (state.current !== void 0) {
236
+ if (!baseline && state.current !== lastCurrent) closeNotification(state.current);
237
+ lastCurrent = state.current;
238
+ }
184
239
  const seen = /* @__PURE__ */ new Set();
185
240
  for (const id of Object.keys(state.byId)) {
186
241
  const summary = state.byId[id];
@@ -194,27 +249,41 @@ window.__ModuleLoader__.load({
194
249
  };
195
250
  tracks.set(id, track);
196
251
  }
197
- if (!baseline && track.running && !summary.running) ding("DSH 完成了一轮", titleOf(summary, id) + " 已空闲");
252
+ if (!baseline) {
253
+ if (track.running && !summary.running) ding(id, "DSH 完成了一轮", titleOf(summary, id) + " 已空闲");
254
+ if (!track.running && summary.running) closeNotification(id);
255
+ }
198
256
  track.running = summary.running;
199
257
  }
200
- for (const id of Array.from(tracks.keys())) if (!seen.has(id)) tracks.delete(id);
258
+ for (const id of Array.from(tracks.keys())) if (!seen.has(id)) {
259
+ closeNotification(id);
260
+ tracks.delete(id);
261
+ }
201
262
  }
202
263
  /**
203
264
  * 用一帧待回答交互快照做边沿检测:某会话冒出新的 key 时响一次。
204
265
  *
205
- * 消失的 key 只清理记录,不触发任何声音。
266
+ * 先处理「旧 key 失效」:被解决或被新 key 顶掉时关掉该会话的通知;
267
+ * 再处理「新 key 出现」并叮咚,因此替换场景下新通知不会被误关。
206
268
  *
207
269
  * @param pending 当前按会话索引的待回答交互快照。
208
270
  * @param state 与本次快照同时读到的会话列表,用来取标题。
271
+ * @param baseline 是否首帧,首帧只建基线。
209
272
  */
210
273
  function scanPending(pending, state, baseline) {
274
+ if (!baseline) for (const [id, track] of tracks) {
275
+ if (track.pendingKeys.size === 0) continue;
276
+ const currentKey = pending.get(id)?.key;
277
+ if (currentKey !== void 0 && track.pendingKeys.has(currentKey)) continue;
278
+ closeNotification(id);
279
+ }
211
280
  for (const [id, interaction] of pending) {
212
281
  const track = tracks.get(id);
213
282
  if (!track) continue;
214
283
  if (track.pendingKeys.has(interaction.key)) continue;
215
284
  track.pendingKeys.add(interaction.key);
216
285
  if (baseline) continue;
217
- ding("DSH 需要你回答", titleOf(state.byId[id], id) + " 正在等你");
286
+ ding(id, "DSH 需要你回答", titleOf(state.byId[id], id) + " 正在等你");
218
287
  }
219
288
  for (const [id, track] of tracks) {
220
289
  if (track.pendingKeys.size === 0) continue;
@@ -235,6 +304,9 @@ window.__ModuleLoader__.load({
235
304
  if (typeof Notification !== "undefined" && Notification.permission === "default") armPermissionGesture();
236
305
  const pendingSource = ctx.uiSession.pendingInteractions;
237
306
  const listSource = ctx.sessions.list;
307
+ const open = ctx.sessions.open;
308
+ sessionOpener = typeof open === "function" ? open.bind(ctx.sessions) : void 0;
309
+ listSourceRef = listSource;
238
310
  /** 是否已经用首帧快照建立过基线;首帧只记录不发声。 */
239
311
  let initialized = false;
240
312
  /** 读两个源并各扫一遍,保证两处看到的是同一帧会话列表。 */
@@ -266,6 +338,10 @@ window.__ModuleLoader__.load({
266
338
  deferredTimer = 0;
267
339
  }
268
340
  deferred = null;
341
+ closeAllNotifications();
342
+ sessionOpener = void 0;
343
+ listSourceRef = void 0;
344
+ lastCurrent = void 0;
269
345
  tracks.clear();
270
346
  });
271
347
  }
package/lib/index.d.ts CHANGED
@@ -1,17 +1,15 @@
1
- import { Context } from "@deepseek-ai/cordis";
2
1
  //#region src/index.d.ts
3
- /** 插件名,同时也是配置项 id。 */
4
- export declare const name = "dsh-notify-ding";
5
- /** 依赖宿主 web 载体的具名路由注册能力。没有 web 载体时本插件不激活。 */
6
- export declare const inject: string[];
7
- /** Windows 通知音的绝对路径,本家音色。 */
8
- export declare const SOUND_FILE = "C:\\Windows\\Media\\Windows Notify System Generic.wav";
9
- /** 一次「叮咚」播放的次数,一声即可,重复反而显得吵。 */
10
- export declare const BEEP_COUNT = 1;
11
2
  /**
12
- * 挂载播音路由。
3
+ * dsh-notify-ding 的宿主入口。
4
+ *
5
+ * 提示音交给系统通知自带,插件不再自行发声,因此本入口没有运行时行为。
6
+ * 之所以还留一个入口文件,是因为客户端模块系统按 Loader entry 扫描
7
+ * dsh.client 声明:entry 指向的宿主模块必须存在,浏览器半身才会被加载。
13
8
  *
14
- * @param ctx 宿主上下文,webServer 服务已就绪。
9
+ * @module dsh-notify-ding
15
10
  */
16
- export declare function apply(ctx: Context): void;
11
+ /** 插件名,同时也是配置项 id。 */
12
+ export declare const name = "dsh-notify-ding";
13
+ /** 无宿主行为,仅作为可挂载的宿主入口存在。 */
14
+ export declare function apply(): void;
17
15
  //#endregion
package/lib/index.js CHANGED
@@ -1,114 +1,16 @@
1
- import { spawn } from "node:child_process";
2
1
  //#region src/index.ts
3
2
  /**
4
- * dsh-notify-ding 的宿主半身。
3
+ * dsh-notify-ding 的宿主入口。
5
4
  *
6
- * 职责只有一件:在 dsh 的 web 载体上注册一条精确路由 POST /dsh-notify-ding/ding,
7
- * 收到请求就用 PowerShell 的 System.Media.SoundPlayer 播放 Windows 自带的提示音。
8
- * 之所以要绕宿主这一圈,是因为浏览器沙箱读不到 C:\Windows\Media 下的文件,
9
- * 而 Windows 本家音色只有读得到那些 wav 的宿主进程才播得出来。
10
- *
11
- * 浏览器半身负责判断「该响了」;本半身不参与任何触发判断,只负责发声。
5
+ * 提示音交给系统通知自带,插件不再自行发声,因此本入口没有运行时行为。
6
+ * 之所以还留一个入口文件,是因为客户端模块系统按 Loader entry 扫描
7
+ * dsh.client 声明:entry 指向的宿主模块必须存在,浏览器半身才会被加载。
12
8
  *
13
9
  * @module dsh-notify-ding
14
10
  */
15
11
  /** 插件名,同时也是配置项 id。 */
16
12
  const name = "dsh-notify-ding";
17
- /** 依赖宿主 web 载体的具名路由注册能力。没有 web 载体时本插件不激活。 */
18
- const inject = ["webServer"];
19
- /** 播音路由的绝对路径。 */
20
- const ROUTE_PATH = "/dsh-notify-ding/ding";
21
- /** Windows 通知音的绝对路径,本家音色。 */
22
- const SOUND_FILE = "C:\\Windows\\Media\\Windows Notify System Generic.wav";
23
- /** 一次「叮咚」播放的次数,一声即可,重复反而显得吵。 */
24
- const BEEP_COUNT = 1;
25
- /**
26
- * 组装播音用的 PowerShell 脚本。
27
- *
28
- * 路径经单引号转义后拼进脚本字面量,避免文件名里的特殊字符被当成命令解析。
29
- * 播放次数写进脚本,因此一次进程启动就能响完整段声音,不必每声各起一个进程。
30
- *
31
- * @returns 一段自包含的 PowerShell 脚本。
32
- */
33
- function buildPlayScript() {
34
- return [
35
- "$ErrorActionPreference = 'Stop'",
36
- "$ProgressPreference = 'SilentlyContinue'",
37
- "$player = New-Object System.Media.SoundPlayer '" + SOUND_FILE.replace(/'/g, "''") + "'",
38
- "for ($i = 0; $i -lt 1; $i++) {",
39
- " $player.PlaySync()",
40
- "}"
41
- ].join("\n");
42
- }
43
- /**
44
- * 把播音请求交给一个脱离宿主事件循环的 PowerShell 进程。
45
- *
46
- * 用 detached 启动,因此响铃不会被 dsh 的生命周期或响应结束打断;
47
- * stderr 仍要接住,否则播放失败时宿主会留下无人消费的管道数据。
48
- *
49
- */
50
- function spawnPlayer() {
51
- const script = buildPlayScript();
52
- const encoded = Buffer.from(script, "utf16le").toString("base64");
53
- const child = spawn("powershell.exe", [
54
- "-NoLogo",
55
- "-NoProfile",
56
- "-NonInteractive",
57
- "-EncodedCommand",
58
- encoded
59
- ], {
60
- detached: true,
61
- stdio: [
62
- "ignore",
63
- "ignore",
64
- "pipe"
65
- ],
66
- windowsHide: true
67
- });
68
- child.stderr?.on("data", (chunk) => {
69
- const text = chunk.toString("utf8").trim();
70
- if (text) console.warn("[dsh-notify-ding] 播音进程报错:" + text);
71
- });
72
- child.on("error", (error) => {
73
- console.warn("[dsh-notify-ding] 播音进程启动失败:" + error.message);
74
- });
75
- child.on("close", (code) => {
76
- if (code !== 0 && code !== null) console.warn("[dsh-notify-ding] 播音进程退出码:" + String(code));
77
- });
78
- child.unref();
79
- }
80
- /**
81
- * 挂载播音路由。
82
- *
83
- * @param ctx 宿主上下文,webServer 服务已就绪。
84
- */
85
- function apply(ctx) {
86
- const webServer = ctx.get("webServer");
87
- if (!webServer) {
88
- console.warn("[dsh-notify-ding] 未找到 webServer 服务,播音路由不注册。");
89
- return;
90
- }
91
- webServer.register({
92
- kind: "exact",
93
- path: ROUTE_PATH,
94
- handler: (req, res) => {
95
- if (req.method !== "POST") {
96
- res.statusCode = 405;
97
- res.end("method not allowed");
98
- return;
99
- }
100
- try {
101
- spawnPlayer();
102
- } catch (error) {
103
- console.warn("[dsh-notify-ding] 播音调用失败:" + error.message);
104
- res.statusCode = 500;
105
- res.end("spawn failed");
106
- return;
107
- }
108
- res.statusCode = 200;
109
- res.end("ding");
110
- }
111
- });
112
- }
13
+ /** 无宿主行为,仅作为可挂载的宿主入口存在。 */
14
+ function apply() {}
113
15
  //#endregion
114
- export { BEEP_COUNT, SOUND_FILE, apply, inject, name };
16
+ export { apply, name };
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "dsh-notify-ding",
3
- "version": "0.1.0",
4
- "description": "在 DSH 需要人工回答或一轮对话跑完时,弹出系统通知并让宿主播放 Windows 内置提示音。标准可安装的 dsh 客户端插件。",
3
+ "version": "0.1.2",
4
+ "description": "在 DSH 需要人工回答或一轮对话跑完时弹出系统通知。标准可安装的 dsh 客户端插件。",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
8
8
  "dsh-plugin",
9
9
  "notification",
10
- "sound",
11
10
  "notify"
12
11
  ],
13
12
  "repository": {