dsh-notify-ding 0.1.1 → 0.1.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
@@ -1,8 +1,8 @@
1
1
  # dsh·通知叮咚插件
2
2
 
3
- 在 DSH 需要你人工回答或一轮对话跑完时,弹出系统通知,并让宿主播放 Windows 自带的提示音。
3
+ 在 DSH 等待你回复或一轮对话跑完时,弹出系统通知。
4
4
 
5
- 纯浏览器端触发、宿主端发声的双半身插件,不替换任何原生界面。
5
+ 纯浏览器端的插件,不替换任何原生界面。
6
6
 
7
7
  ## 引言
8
8
 
@@ -11,9 +11,9 @@
11
11
 
12
12
  ## 功能
13
13
 
14
- - 某个会话出现新的待人工回答交互时叮咚一次,question、plan-review、approval 都算。
14
+ - 某个会话出现新的等待回复交互时叮咚一次,question、plan-review、approval 都算。
15
15
  - 某个会话从执行中落到空闲、也就是一轮对话跑完时叮咚一次。
16
- - 两种触发都会弹浏览器系统通知,同时请宿主播放 Windows 内置提示音。
16
+ - 两种触发都会弹一条浏览器系统通知,提示音由 Windows 通知系统自带。
17
17
  - 不判断页面是否聚焦,也不区分是否为当前会话,任何会话有事都会响。
18
18
  - 通知不设自动关闭时间,会一直留在系统通知里,直到对应会话发生操作:会话被选中、该会话开始新一轮、待回答被解决,或会话从列表移除。
19
19
  - 每个会话最多保留一条通知,新通知会顶掉同会话的旧通知;点击通知会聚焦窗口并切到该会话。
@@ -25,11 +25,17 @@
25
25
 
26
26
  浏览器系统通知需要页面授权。插件在加载时会尝试请求一次,若被浏览器拒绝,会在你下一次点击或按键时再请求一次。所以在第一次看到页面后点一下任意位置,授权弹窗就会出来。
27
27
 
28
- 若浏览器通知一直弹不出来,声音仍然会正常响——两条路是独立的。
28
+ ### 提示音从哪来
29
29
 
30
- ### 声音从哪来
30
+ 插件不自行发声,提示音由 Windows 的通知系统在弹通知时自带。提示音的音量与静音都跟着系统通知设置走,和插件本身无关。
31
31
 
32
- 浏览器沙箱读不到 `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
+ 此时插件没有任何声音提示,因为它不自己发声,提示音就是被一并静音的通知音。
33
39
 
34
40
  ## 安装
35
41
 
@@ -57,15 +63,15 @@ dsh plugin --profile web remove dsh-notify-ding
57
63
 
58
64
  ## 工作原理
59
65
 
60
- 浏览器半身订阅 `ctx.sessions.list` 与 `ctx.uiSession.pendingInteractions` 两个只读快照,用边沿检测判断该不该响:运行态从真落到假算一轮跑完,出现新 key 的待回答交互算需要人工介入。首帧只建基线不发声,同一待回答请求只响一次,两次响铃之间留至少 800 毫秒并把该时刻写入 localStorage 跨标签页共享。
66
+ 浏览器半身订阅 `ctx.sessions.list` 与 `ctx.uiSession.sessionStatus` 两个只读快照,用边沿检测判断该不该响:运行态从真落到假算一轮跑完,出现新 key 的待回答交互算需要人工介入。首帧只建基线不发声,同一待回答请求只响一次,两次响铃之间留至少 800 毫秒并把该时刻写入 localStorage 跨标签页共享。
61
67
 
62
- 判定通过时它做两件事:弹浏览器系统通知,以及向宿主发一个 POST。宿主半身只负责后者,它注册的 `/dsh-notify-ding/ding` 路由收到请求就用 PowerShell 播放提示音。通知按会话记录且不设自动关闭,同一会话只留最新一条;会话被选中、开始新一轮、待回答被解决或从列表移除时会关掉对应通知,点击通知则聚焦窗口、切换过去并关掉该条。完整设计见 [设计说明](docs/design.md)。
68
+ 判定通过时它弹出一条浏览器系统通知。通知按会话记录且不设自动关闭,同一会话只留最新一条;会话被选中、开始新一轮、待回答被解决或从列表移除时会关掉对应通知,点击通知则聚焦窗口、切换过去并关掉该条。完整设计见 [设计说明](docs/design.md)。
63
69
 
64
70
  ## 要求与开发
65
71
 
66
72
  - 是标准形态的 dsh client 插件,声明 `dsh.client`,导出 `./client`。
67
73
  - 同时声明了 `dsh.bundle`,因此也是一个自挂载的 bundle 层插件:用 `dsh plugin --profile <name> add` 从 GitHub 安装后会被自动识别为 profile layer 并挂载,无需手工写组合 entry。
68
- - 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`。
74
+ - 浏览器半身依赖 `uiSession` 与 `sessions`;宿主入口没有运行时行为,只为让包能被 Loader 加载。
69
75
  - 构建型插件:`src/` 是 TypeScript 源码,`lib/` 是构建产物且不入库,安装或发布前由 `prepare` 构建。
70
76
 
71
77
  ## 开发
@@ -76,7 +82,7 @@ pnpm run typecheck # tsc --noEmit 严格类型检查
76
82
  pnpm run build # tsdown,产出 lib/index.js 与 lib/client.js
77
83
  ```
78
84
 
79
- - `src/index.ts`:宿主半身,注册播音路由。
85
+ - `src/index.ts`:宿主入口,无运行时行为,只用于让包被 Loader 加载。
80
86
  - `src/client/index.ts`:浏览器半身,边沿检测与通知。
81
87
  - 构建用 tsdown,client 产物是 `window.__ModuleLoader__.load({ id, factory })` 的注册式模块,`@deepseek-ai/cordis` 保持外部依赖。
82
88
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-notify-ding bundle patch:把通知插件插进 profile 的 layer stack。
2
2
  # 当本 bundle 出现在 profile 的 dsh.profile.bundles 列表里时应用。
3
- # 主机端半身负责宿主的提示音路由与可选的窗口提示音。
3
+ # 宿主半身没有运行时行为,只让包能被 Loader 加载。
4
4
  # 浏览器半身由包的 dsh.client 声明与 exports["./client"] 自动发现。
5
5
 
6
6
  - insert:
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
 
@@ -15,7 +12,7 @@
15
12
 
16
13
  | 信号 | 来源 | 触发条件 |
17
14
  | --- | --- | --- |
18
- | 待人工回答 | `ctx.uiSession.pendingInteractions` | 某会话出现新 key 的交互,question、plan-review、approval 都算 |
15
+ | 等待回复 | `ctx.uiSession.sessionStatus` | 某会话出现新 key 的交互,question、plan-review、approval 都算 |
19
16
  | 一轮跑完 | `ctx.sessions.list` 的 `byId[].running` | 某会话运行态从真落到假 |
20
17
 
21
18
  两者都是可订阅的只读快照,插件不修改任何状态,只观察。
@@ -45,18 +42,13 @@
45
42
 
46
43
  只识别这四种状态信号,因此通知只有在 DSH 里真的对那个会话动了手才会消失;单纯回到浏览器窗口不会关掉它。
47
44
 
48
- ## 声音与通知的关系
49
-
50
- 两者互相独立:浏览器通知弹不出来时声音照响,宿主播音失败时通知照弹。任一侧失败只记一条 `console.warn`,不抛错、不互相阻塞。
51
-
52
- ## 认证
45
+ ## 全屏静默
53
46
 
54
- dsh web 由 connection 插件提供认证边界:根路径带 token 打开后铸出浏览器 cookie,后续同源请求自动带上。插件走同源 fetch 且带 `credentials: 'same-origin'`,因此沿用同一套 cookie,不需要自己处理 token。
47
+ Windows 11 的「专注」有一条自动规则「当我在全屏使用应用时」,全屏看视频或玩游戏时会自动进入专注状态,通知横幅不显示、通知音被静音,通知只落进通知中心。插件不自己发声,提示音就是系统通知自带的音效,所以这种场合没有任何声音提示。
55
48
 
56
49
  ## 已知边界
57
50
 
58
- - 宿主半身依赖 `webServer` 服务,浏览器半身依赖 `uiSession` 与 `sessions`,缺任一则不激活。
59
- - 播音走 PowerShell 进程,单次开销约是一百毫秒级,慢但不阻塞响应。
51
+ - 浏览器半身依赖 `uiSession` 与 `sessions`,缺任一则不激活。
60
52
  - 「一轮跑完」只覆盖正常结束与被打断这两种空闲落点,插件不区分具体结束原因。
61
- - Windows 聚焦助手开启时系统通知可能被静音,但宿主播音不受影响。
53
+ - 系统通知被静默时横幅和声音一起消失,插件察觉不到,也没有补偿手段。
62
54
  - 页面被关掉后,浏览器通知可能仍留在系统通知中心,此时插件已不在运行,无法再收回,只能手动清掉。
package/lib/client.js CHANGED
@@ -8,12 +8,27 @@ 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
+ * @module dsh-notify-ding/client
27
+ */
11
28
  /** 插件名,同时也是配置项 id。 */
12
29
  const name = "dsh-notify-ding";
13
30
  /** 本插件需要的客户端服务。 */
14
31
  const inject = ["uiSession", "sessions"];
15
- /** 宿主播音路由,与宿主半身注册的路径一致。 */
16
- const DING_PATH = "/dsh-notify-ding/ding";
17
32
  /** 两次响铃之间的最小间隔,窗口内的合并成一次。 */
18
33
  const MIN_INTERVAL_MS = 800;
19
34
  /** 跨标签页共享「上次响铃时刻」的 localStorage 键。 */
@@ -37,8 +52,7 @@ window.__ModuleLoader__.load({
37
52
  /**
38
53
  * 读取跨标签页共享的上次响铃时刻。
39
54
  *
40
- * localStorage 可能因隐私模式或禁用存储而抛错,抛错时按 0 处理即可,
41
- * 最坏退化成只在单个标签页内节流。
55
+ * localStorage 可能因隐私模式或禁用存储而抛错,抛错时按 0 处理即可,最坏退化成只在单个标签页内节流。
42
56
  *
43
57
  * @returns 上次响铃的 Unix 毫秒时间戳。
44
58
  */
@@ -60,20 +74,6 @@ window.__ModuleLoader__.load({
60
74
  console.warn("[dsh-notify-ding] 写入共享响铃时刻失败:" + String(error));
61
75
  }
62
76
  }
63
- /**
64
- * 请求宿主播放提示音。
65
- *
66
- * 声音本身与系统通知互相独立:通知弹不出来时声音照样响,反之亦然。
67
- * 这里不阻塞调用方,播音失败只记一条警告。
68
- */
69
- function requestHostDing() {
70
- fetch(DING_PATH, {
71
- method: "POST",
72
- credentials: "same-origin"
73
- }).catch((error) => {
74
- console.warn("[dsh-notify-ding] 请求宿主播音失败:" + String(error));
75
- });
76
- }
77
77
  /** 关掉某会话的通知;没有或已关就当无操作。 */
78
78
  function closeNotification(sessionId) {
79
79
  const notification = liveNotifications.get(sessionId);
@@ -113,8 +113,7 @@ window.__ModuleLoader__.load({
113
113
  * 每个会话最多保留一条,新的会先关掉同会话的旧的。
114
114
  * 点击通知会聚焦窗口并切到该会话,同时关掉这条通知。
115
115
  *
116
- * 浏览器未授权时不强行索要权限,而是记下需要补授权,等到用户下一次
117
- * 与页面交互时再请求——权限请求必须由用户手势触发。
116
+ * 浏览器未授权时不强行索要权限,而是记下需要补授权,等到用户下一次与页面交互时再请求——权限请求必须由用户手势触发。
118
117
  *
119
118
  * @param sessionId 这条通知所属的会话。
120
119
  * @param title 通知标题。
@@ -171,21 +170,10 @@ window.__ModuleLoader__.load({
171
170
  window.addEventListener("keydown", onGesture, true);
172
171
  }
173
172
  /**
174
- * 发一次「叮咚」:弹系统通知并请宿主播音。
175
- * @param sessionId 这条通知所属的会话。
176
- * @param title 通知标题。
177
- * @param body 通知正文。
178
- */
179
- function emit(sessionId, title, body) {
180
- showSystemNotification(sessionId, title, body);
181
- requestHostDing();
182
- }
183
- /**
184
173
  * 发一次「叮咚」,并做节流。
185
174
  *
186
175
  * 节流时刻写进 localStorage,多标签页共享,避免多开时同一件事响好几轮。
187
- * 被节流压下的请求不会直接丢弃,而是挂到窗口边界的定时器上补发,
188
- * 这样连续两次状态变化仍然各响一次,只是间隔被拉开,不会出现漏报。
176
+ * 被节流压下的请求不会直接丢弃,而是挂到窗口边界的定时器上补发,这样连续两次状态变化仍然各响一次,只是间隔被拉开,不会出现漏报。
189
177
  *
190
178
  * @param sessionId 这条通知所属的会话。
191
179
  * @param title 通知标题。
@@ -197,7 +185,7 @@ window.__ModuleLoader__.load({
197
185
  if (since < 0 || since >= MIN_INTERVAL_MS) {
198
186
  lastDingAt = now;
199
187
  writeSharedLastDing(now);
200
- emit(sessionId, title, body);
188
+ showSystemNotification(sessionId, title, body);
201
189
  return;
202
190
  }
203
191
  deferred = {
@@ -214,7 +202,7 @@ window.__ModuleLoader__.load({
214
202
  const stamp = Date.now();
215
203
  lastDingAt = stamp;
216
204
  writeSharedLastDing(stamp);
217
- emit(pending.sessionId, pending.title, pending.body);
205
+ showSystemNotification(pending.sessionId, pending.title, pending.body);
218
206
  }, MIN_INTERVAL_MS - since);
219
207
  }
220
208
  /** 会话标题,缺失时退回会话 id,保证通知里总有个能认的名字。 */
@@ -226,13 +214,9 @@ window.__ModuleLoader__.load({
226
214
  * 用一帧会话列表快照做边沿检测:新出现的待回答交互响一次,运行态从真落到假响一次。
227
215
  *
228
216
  * 首帧只建基线不响,否则每次刷新页面都会把既有状态补报一轮。
229
- * 同时识别「会话被操作」的信号,把对应会话还挂着的通知关掉:
230
- * 运行态从假升到真说明已回到该会话开始新一轮,会话被选中,
231
- * 以及会话从列表消失,都算一次操作。
217
+ * 同时识别「会话被操作」的信号,把对应会话还挂着的通知关掉:运行态从假升到真说明已回到该会话开始新一轮,会话被选中,以及会话从列表消失,都算一次操作。
232
218
  *
233
- * 选中变化先于响铃处理,否则同一帧里既跑完又刚被选中的会话,通知会建立后
234
- * 立刻被关掉。current 短暂变空是 DSH 的 masked gap,选中会话暂时不在列表时
235
- * 就会出现,不算一次操作,也不该把上一个有效选中抹掉。
219
+ * 选中变化先于响铃处理,否则同一帧里既跑完又刚被选中的会话,通知会建立后立刻被关掉。current 短暂变空是 DSH 的 masked gap,选中会话暂时不在列表时就会出现,不算一次操作,也不该把上一个有效选中抹掉。
236
220
  *
237
221
  * @param state 当前会话列表快照。
238
222
  * @param baseline 是否首帧,首帧只建基线。
@@ -301,6 +285,12 @@ window.__ModuleLoader__.load({
301
285
  for (const key of Array.from(track.pendingKeys)) if (key !== stillPending.key) track.pendingKeys.delete(key);
302
286
  }
303
287
  }
288
+ /** 从会话状态快照里投影出待回答交互,按会话 id 索引。 */
289
+ function readPending(statusSource) {
290
+ const projected = /* @__PURE__ */ new Map();
291
+ for (const [id, row] of statusSource.getSnapshot()) if (row.pendingInteraction) projected.set(id, row.pendingInteraction);
292
+ return projected;
293
+ }
304
294
  /**
305
295
  * 同时盯住会话列表与待回答交互两个快照源。
306
296
  *
@@ -308,7 +298,7 @@ window.__ModuleLoader__.load({
308
298
  */
309
299
  function apply(ctx) {
310
300
  if (typeof Notification !== "undefined" && Notification.permission === "default") armPermissionGesture();
311
- const pendingSource = ctx.uiSession.pendingInteractions;
301
+ const statusSource = ctx.uiSession.sessionStatus;
312
302
  const listSource = ctx.sessions.list;
313
303
  const open = ctx.sessions.open;
314
304
  sessionOpener = typeof open === "function" ? open.bind(ctx.sessions) : void 0;
@@ -328,13 +318,13 @@ window.__ModuleLoader__.load({
328
318
  initialized = true;
329
319
  scanSessions(state, baseline);
330
320
  try {
331
- scanPending(pendingSource.getSnapshot(), state, baseline);
321
+ scanPending(readPending(statusSource), state, baseline);
332
322
  } catch (error) {
333
323
  console.warn("[dsh-notify-ding] 读取待回答交互失败:" + String(error));
334
324
  }
335
325
  };
336
326
  const unsubscribeList = listSource.subscribe(scan);
337
- const unsubscribePending = pendingSource.subscribe(scan);
327
+ const unsubscribePending = statusSource.subscribe(scan);
338
328
  scan();
339
329
  ctx.effect(() => () => {
340
330
  unsubscribeList();
package/lib/index.d.ts CHANGED
@@ -1,17 +1,14 @@
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 扫描 dsh.client 声明:entry 指向的宿主模块必须存在,浏览器半身才会被加载。
13
7
  *
14
- * @param ctx 宿主上下文,webServer 服务已就绪。
8
+ * @module dsh-notify-ding
15
9
  */
16
- export declare function apply(ctx: Context): void;
10
+ /** 插件名,同时也是配置项 id。 */
11
+ export declare const name = "dsh-notify-ding";
12
+ /** 无宿主行为,仅作为可挂载的宿主入口存在。 */
13
+ export declare function apply(): void;
17
14
  //#endregion
package/lib/index.js CHANGED
@@ -1,114 +1,15 @@
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 扫描 dsh.client 声明:entry 指向的宿主模块必须存在,浏览器半身才会被加载。
12
7
  *
13
8
  * @module dsh-notify-ding
14
9
  */
15
10
  /** 插件名,同时也是配置项 id。 */
16
11
  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
- }
12
+ /** 无宿主行为,仅作为可挂载的宿主入口存在。 */
13
+ function apply() {}
113
14
  //#endregion
114
- export { BEEP_COUNT, SOUND_FILE, apply, inject, name };
15
+ export { apply, name };
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "dsh-notify-ding",
3
- "version": "0.1.1",
4
- "description": "在 DSH 需要人工回答或一轮对话跑完时,弹出系统通知并让宿主播放 Windows 内置提示音。标准可安装的 dsh 客户端插件。",
3
+ "version": "0.1.3",
4
+ "description": "「DSH·通知叮咚」:在 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": {
@@ -56,6 +55,10 @@
56
55
  "build": "tsdown",
57
56
  "typecheck": "tsc --noEmit"
58
57
  },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/dsh": "^0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.7-rc.2"
61
+ },
59
62
  "devDependencies": {
60
63
  "@deepseek-ai/cordis": "^4.0.2",
61
64
  "@types/node": "^22.20.1",