dsh-plugin-notify-sounds 1.5.0 → 1.6.1

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,5 +1,7 @@
1
1
  # dsh-plugin-notify-sounds
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-plugin-notify-sounds.svg)](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
4
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-plugin-notify-sounds.svg)](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
3
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
6
  [![DeepSeek Harness plugin](https://img.shields.io/badge/dsh-plugin-8b5cf6.svg)](https://github.com/deepseek-ai/deepseek-harness)
5
7
 
@@ -12,11 +14,15 @@ Walk away from the window without missing anything: an OS notification in the ba
12
14
  **Install**: DSH → Settings → Plugins → Add plugin, paste the line below and press **Install**:
13
15
 
14
16
  ```
15
- https://github.com/BillCx330/dsh-plugin-notify-sounds
17
+ dsh-plugin-notify-sounds
16
18
  ```
17
19
 
18
20
  ---
19
21
 
22
+ ## Consistent with DSH's own UI
23
+
24
+ Every visual detail of the settings section comes from DSH's own design system: row geometry, dividers, type sizes and spacing use the official design tokens, the sound picker is the official `Menu` primitive, the icons come from the official family, and the layout follows the native text-left, control-right row shape. Installing it does not introduce a visually disjoint third-party block into your settings.
25
+
20
26
  ## What it does
21
27
 
22
28
  | What | What you get |
@@ -28,6 +34,7 @@ https://github.com/BillCx330/dsh-plugin-notify-sounds
28
34
  | **Only what you care about** | Sub-agents (background tasks) are completely silent by default — only your own conversation is announced |
29
35
  | **Quiet when it should be** | Quiet hours (e.g. 22:00–08:00, wrapping past midnight), notify-only-when-hidden, and a master switch |
30
36
  | **Your sound, your way** | Nine built-in sounds, custom ringtone upload, volume — each feature with its own toggle |
37
+ | **Updates itself** | Check npm for a new version and install it in one click: verified against the published digest before anything is replaced, restored on failure; restart DSH to load it |
31
38
 
32
39
  ## Why
33
40
 
@@ -37,17 +44,15 @@ This plugin turns "it is waiting for you" and "it is done" into sound. With syst
37
44
 
38
45
  ## Sounds
39
46
 
40
- Nine Web Audio synthesized sounds — no audio files, no network requests:
47
+ Bell · Chime · Ding · Drop · Pulse · Arc · Coin · Success · Knock — nine built-in sounds, all synthesized live with Web Audio: no audio files, no network requests.
41
48
 
42
- Bell · Chime · Ding · Drop · Pulse · Arc · Coin · Success · Knock
43
-
44
- Or upload your own ringtone (mp3/wav/ogg, ≤512KB, validated and volume-normalized automatically), or pick "None" for notifications only. Each trigger (approval / question / completion) can use a different sound, with preview right in the settings.
49
+ Or upload your own ringtone (mp3/wav/ogg, ≤512KB, validated and volume-normalized automatically), or pick "None" for notifications only. Each trigger (approval / question / completion) gets its own sound, with preview right in the settings.
45
50
 
46
51
  ## Settings
47
52
 
48
53
  Settings → **Sounds** (between General and Models). Every feature has its own toggle, styled exactly like DSH's own preference pages:
49
54
 
50
- master switch and volume · per-trigger sounds with preview · sub-agent alerts · system notifications · window title alert · repeat interval · quiet hours · hidden-only mode · custom ringtone upload
55
+ master switch and volume · per-trigger sounds with preview · sub-agent alerts · system notifications · window title alert · repeat interval · quiet hours · hidden-only mode · custom ringtone upload · update checks and one-click update
51
56
 
52
57
  Everything applies immediately — no restart needed.
53
58
 
@@ -56,20 +61,20 @@ Everything applies immediately — no restart needed.
56
61
  ### Simplest: two clicks inside DSH (recommended)
57
62
 
58
63
  1. In DSH open **Settings → Plugins** and press **Add plugin**;
59
- 2. Paste this into the box:
64
+ 2. Paste the package name into the box (this plugin is published on [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds)):
60
65
 
61
66
  ```
62
- https://github.com/BillCx330/dsh-plugin-notify-sounds
67
+ dsh-plugin-notify-sounds
63
68
  ```
64
69
 
65
- (that is the "GitHub repository address" the box asks for; `github:BillCx330/dsh-plugin-notify-sounds` or a local directory path work too)
70
+ (the GitHub repository address `https://github.com/BillCx330/dsh-plugin-notify-sounds` or a local directory path work too)
66
71
  3. Press **Install**, then **restart DSH** once.
67
72
 
68
73
  The "install source" dropdown above the box picks the download mirror (mainland China mirror by default; leave it alone unless downloads are slow). DSH warns that a plugin runs with your permissions — this one only reads session state and plays sounds locally; its single request goes to **your own DSH** to raise the window when you click a notification, and nothing is sent anywhere else.
69
74
 
70
75
  ### Upgrading
71
76
 
72
- **There is no auto-update**: to upgrade, **uninstall** the old version from the same place first, then install the new one as above.
77
+ DSH does not update plugins on its own, but this plugin **updates itself**: Settings → Sounds → Updates can check for a new version and install it in one click. Uninstalling and installing again also works.
73
78
 
74
79
  ### Other ways
75
80
 
@@ -94,6 +99,7 @@ The command line writes `dsh.profile.bundles` and installs the dependency for yo
94
99
  ## Known limitations
95
100
 
96
101
  - **Hand-edited config**: preferences live in the profile's `cordis.patch.yml`; values must match the schema (volume 0–100, a preset id, `HH:MM` times) or the plugin declines to load — just fix the value. The settings UI only ever writes valid values.
102
+ - **One-click update only swaps files**: it replaces the seven files this package ships and **never touches the profile's configuration**. Downloaded code is never executed — the new code loads on a **DSH restart**. Let the row report a result before closing the window (an interrupted update restores itself, but there is no reason to gamble); if a future release adds a dependency, that needs installing separately (this one has none).
97
103
  - **Multiple windows** fire independently: the same event may chime once per window; turn on notify-only-when-hidden to avoid it.
98
104
  - **Quiet hours hold reminders, they do not drop them**: a repeat reminder that becomes due during the quiet range fires right after it ends, and several pending decisions can fire at once then.
99
105
  - **Click-to-return** is fully implemented and tested on Windows; macOS / Linux fall back to the system deep link and are unverified.
@@ -117,6 +123,8 @@ For anyone hacking on the code (exact details live in the source comments):
117
123
 
118
124
  ## Changelog
119
125
 
126
+ - **v1.6.1** (2026-10-11) The README is rebuilt as a release page: a new **Consistent with DSH's own UI** section on how the UI matches DSH's own settings, copy polished throughout, and the upgrade notes corrected — the plugin updates itself, so no uninstall is needed. This release doubles as the live drill for one-click update.
127
+ - **v1.6.0** (2026-10-11) New: **check for updates and install them in one click**. The settings section can fetch the newest npm version and install it: the download is checked against the registry's published sha512, only whitelisted files are unpacked (symlinks and path traversal are refused), the old files are kept aside, and any failure restores them. Downloaded code is never executed — the new code loads when DSH restarts. Automatic checks run at most once a day and can be turned off, with the mainland-China mirror as fallback.
120
128
  - **v1.5.0** (2026-10-08) The "finished" title mark is unread news now — set by a finish that happens while you are away, cleared when you return — instead of reappearing on every switch-away. The retrigger cooldown is per session, so two conversations deciding within 2 seconds both ring. The Host half gained tests (Config schema, focus route, title matching) that cross-check the shared constants between the halves; the sessions provider is now an explicit load prerequisite, and quiet-hours catch-up is documented.
121
129
 
122
130
  - **v1.4.4** (2026-10-08) Quiet hours now suppress the taskbar title alert too, and the title refreshes as the quiet-hours window begins or ends. Custom-sound upload/removal writes are ordered and roll back on refusal; synchronous config-write errors are handled as save failures.
@@ -143,6 +151,8 @@ node test/smoke.mjs # client half: assembly, triggers, cooldown, quiet hours
143
151
  node test/host.mjs # host half: config schema, focus route, title matching, cross-half contracts
144
152
  ```
145
153
 
154
+ To publish to npm: bump the version, then `npm publish` (`npm login` and 2FA the first time). **npm versions are immutable** — bump for every change.
155
+
146
156
  ```
147
157
  dsh-plugin-notify-sounds/
148
158
  ├── package.json # dsh.bundle.patch + dsh.client
package/README.zh.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-plugin-notify-sounds
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-plugin-notify-sounds.svg)](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
4
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-plugin-notify-sounds.svg)](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
3
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
6
  [![DeepSeek Harness plugin](https://img.shields.io/badge/dsh-plugin-8b5cf6.svg)](https://github.com/deepseek-ai/deepseek-harness)
5
7
 
@@ -12,11 +14,15 @@
12
14
  **安装**:DSH → 设置 → 插件 → 添加插件,粘贴下面这行,点「安装」:
13
15
 
14
16
  ```
15
- https://github.com/BillCx330/dsh-plugin-notify-sounds
17
+ dsh-plugin-notify-sounds
16
18
  ```
17
19
 
18
20
  ---
19
21
 
22
+ ## 与 DSH 界面保持一致
23
+
24
+ 设置面板中的每一处视觉细节都取自 DSH 自身的设计体系:行几何、分隔线、字号与间距沿用官方设计令牌,音效选择器使用官方 `Menu` 组件,图标来自官方图标族,排布沿用「文字左 / 控件右」的原生行式。安装后不会在设置中出现风格割裂的第三方区块。
25
+
20
26
  ## 它做什么
21
27
 
22
28
  | 提醒什么 | 效果 |
@@ -28,6 +34,7 @@ https://github.com/BillCx330/dsh-plugin-notify-sounds
28
34
  | **只提醒你关心的** | 子智能体(后台任务)默认完全静默,只提醒主对话,可开关 |
29
35
  | **该安静时安静** | 静音时段(如 22:00–08:00,可跨午夜)、仅页面不可见时提示、总开关一键全静音 |
30
36
  | **声音随你** | 九种内置音效、自定义铃声上传、音量调节,每项独立开关 |
37
+ | **自己更新** | 设置里检查 npm 新版本并一键更新:下载后校验完整性才替换,失败自动还原;更新后重启 DSH 生效 |
31
38
 
32
39
  ## 为什么需要它
33
40
 
@@ -37,17 +44,15 @@ DSH 的状态提示只有视觉——状态点、侧栏标记。你一切到别
37
44
 
38
45
  ## 声音
39
46
 
40
- 九种 Web Audio 合成音效,不含任何音频文件、不发网络请求:
47
+ 双音铃 · 风铃 · 单音叮 · 水滴 · 脉冲 · 上行琶音 · 金币 · 达成 · 敲门 —— 九种内置音效全部由 Web Audio 现场合成,不含任何音频文件、不发一次网络请求。
41
48
 
42
- 双音铃 · 风铃 · 单音叮 · 水滴 · 脉冲 · 上行琶音 · 金币 · 达成 · 敲门
43
-
44
- 另可上传自己的铃声(mp3/wav/ogg,≤512KB,自动校验与音量归一化),或选「无声」只留通知。三种触发(审批 / 提问 / 完成)可各选各的音效,设置里直接试听。
49
+ 也可以换上你自己的铃声(mp3/wav/ogg,≤512KB,自动校验并做音量归一化),或者干脆选「无声」只留通知。三种触发(审批 / 提问 / 完成)各选各的音效,设置里随时试听。
45
50
 
46
51
  ## 设置
47
52
 
48
53
  设置 → **提示音**(位于「通用设置」与「模型」之间),每项功能都有独立开关,界面与 DSH 原生设置完全一致:
49
54
 
50
- 总开关与音量 · 三种触发各自的音效(含试听)· 子智能体提醒 · 系统通知 · 窗口标题提醒 · 重复提醒间隔 · 静音时段 · 仅后台提示 · 自定义铃声上传
55
+ 总开关与音量 · 三种触发各自的音效(含试听)· 子智能体提醒 · 系统通知 · 窗口标题提醒 · 重复提醒间隔 · 静音时段 · 仅后台提示 · 自定义铃声上传 · 自动检查更新与一键更新
51
56
 
52
57
  改动即时生效,无需重启。
53
58
 
@@ -56,20 +61,20 @@ DSH 的状态提示只有视觉——状态点、侧栏标记。你一切到别
56
61
  ### 最简单:在 DSH 里点两下(推荐)
57
62
 
58
63
  1. 打开 DSH → **设置 → 插件**,点**添加插件**;
59
- 2. 在输入框粘贴:
64
+ 2. 在输入框粘贴包名(本插件已发布在 [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds)):
60
65
 
61
66
  ```
62
- https://github.com/BillCx330/dsh-plugin-notify-sounds
67
+ dsh-plugin-notify-sounds
63
68
  ```
64
69
 
65
- (粘贴的就是「GitHub 仓库地址」;填 `github:BillCx330/dsh-plugin-notify-sounds` 或本地目录路径也可以)
70
+ (填 GitHub 仓库地址 `https://github.com/BillCx330/dsh-plugin-notify-sounds` 或本地目录路径也可以)
66
71
  3. 点**安装**,装完**重启 DSH** 一次。
67
72
 
68
73
  输入框上方的「安装源」是下载源,默认中国大陆镜像源,国内一般不用改。DSH 会提示「插件以你的权限运行」——本插件只读会话状态、在本地发声;唯一的请求是点击系统通知时发给**本机 DSH** 的唤窗请求,不向外部发送任何数据。
69
74
 
70
75
  ### 升级
71
76
 
72
- **装完不支持自动更新**:要升级请先在同一处**卸载**旧版,再按上面的步骤装新版。
77
+ DSH 不会自动更新插件,但本插件**自带更新**:设置 → 提示音 →「更新」里可以检查新版并一键安装。想走手动路径也可以:在同一处先**卸载**旧版,再按上面的步骤装新版。
73
78
 
74
79
  ### 其他安装方式
75
80
 
@@ -94,6 +99,7 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
94
99
  ## 已知边界
95
100
 
96
101
  - **手改配置**:偏好写在 profile 的 `cordis.patch.yml`,值必须符合 schema(音量 0–100、音效为预设之一、时间 `HH:MM`);写错会让插件不加载,改回即可。设置界面只会写入合法值。
102
+ - **一键更新只换文件**:只替换本包内的七个文件,**不碰 profile 配置**;下载内容从不执行,新代码要等**重启 DSH** 才生效。更新过程请等状态行给出结果再关窗口(中断会自动还原,但没必要赌);若未来的新版引入了新依赖,需另行安装(当前零依赖)。
97
103
  - **多窗口**:各窗口独立触发,同一事件可能各响一次;打开「仅在页面不可见时提示」即可避免。
98
104
  - **静音时段是「挂起」不是「丢弃」**:静音期间到期的重复提醒会在时段结束后立即补发,多个待决策可能同时补响。
99
105
  - **点击通知回到窗口**:Windows 完整实现并实测;macOS / Linux 走系统深链,未验证。
@@ -117,6 +123,8 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
117
123
 
118
124
  ## 更新记录
119
125
 
126
+ - **v1.6.1**(2026-10-11)README 重排为发布页风格:补「**与 DSH 界面保持一致**」一节讲清界面取自 DSH 自身设计体系;通篇表达润色,并修正升级说明(本插件自带更新,无需卸载重装)。本版同时是「一键更新」的发布演练。
127
+ - **v1.6.0**(2026-10-11)新增**检查更新与一键更新**:设置里可查 npm 新版本并一键安装——下载后按 registry 公布的 sha512 校验、只解包白名单文件(拒绝符号链接与路径穿越)、先备份再替换、失败自动还原;下载内容从不执行,新代码在 DSH 重启时生效。自动检查每天最多一次、可关;npm 官方源不通时自动改用国内镜像。
120
128
  - **v1.5.0**(2026-10-08)「已完成」标题标记改为未读语义——离开期间发生的完成才标记,回到页面即清除,不再每次切走都重新出现;触发冷却按会话独立,2 秒内两个会话先后需要决策时都会响。补齐宿主半测试(配置 schema、唤窗路由、标题匹配)并交叉校验两半共享常量;`dsh.client.inject` 显式声明 sessions 提供包;静音时段结束后的补发行为写入文档。
121
129
 
122
130
  - **v1.4.4**(2026-10-08)静音时段现在也会屏蔽任务栏标题提醒;进入或离开静音时段时,标题标记会自动刷新。自定义音效上传/移除按顺序写入配置,写入被拒时回滚;配置写入同步抛错也会按保存失败处理。
@@ -143,6 +151,8 @@ node test/smoke.mjs # 客户端半:装配、触发、冷却、静音、重
143
151
  node test/host.mjs # 宿主半:配置 schema、唤窗路由、标题匹配、两半契约交叉校验
144
152
  ```
145
153
 
154
+ 发布到 npm:升好版本号后 `npm publish`(首次发布先 `npm login` 并按提示开启 2FA)。**npm 的版本号不可覆盖**——有任何改动都请升版本再发。
155
+
146
156
  ```
147
157
  dsh-plugin-notify-sounds/
148
158
  ├── package.json # dsh.bundle.patch + dsh.client
package/lib/client.js CHANGED
@@ -178,6 +178,22 @@ window.__ModuleLoader__.load({
178
178
  "notify.decision.body": "智能体正在等待你的决定",
179
179
  "notify.completion.title": "任务完成",
180
180
  "notify.completion.body": "智能体已完成当前任务",
181
+ "update.title": "更新",
182
+ "update.description": "从 npm 检查本插件的新版本(只查询,不下载)",
183
+ "update.auto": "自动检查更新",
184
+ "update.auto.description": "每天最多静默检查一次,发现新版只在下方标记,不弹通知",
185
+ "update.check": "检查更新",
186
+ "update.current": "当前版本",
187
+ "update.idle": "尚未检查",
188
+ "update.checking": "检查中…",
189
+ "update.uptodate": "已是最新",
190
+ "update.available": "有新版",
191
+ "update.failed": "检查失败(网络不可用)",
192
+ "update.apply": "立即更新",
193
+ "update.updating": "更新中…",
194
+ "update.done": "更新完成",
195
+ "update.restart": "重启 DSH 后生效",
196
+ "update.applyFailed": "更新失败,已恢复原状",
181
197
  };
182
198
 
183
199
  /** English dictionary, checked complete against the zh key set. */
@@ -239,6 +255,22 @@ window.__ModuleLoader__.load({
239
255
  "notify.decision.body": "The agent is waiting for your decision",
240
256
  "notify.completion.title": "Task finished",
241
257
  "notify.completion.body": "The agent has finished the current task",
258
+ "update.title": "Updates",
259
+ "update.description": "Check npm for a newer version of this plugin (a query only — nothing is downloaded)",
260
+ "update.auto": "Check for updates automatically",
261
+ "update.auto.description": "At most one quiet check a day; a newer version is only marked below, never announced",
262
+ "update.check": "Check now",
263
+ "update.current": "This version",
264
+ "update.idle": "Not checked yet",
265
+ "update.checking": "Checking…",
266
+ "update.uptodate": "Up to date",
267
+ "update.available": "New version available",
268
+ "update.failed": "Check failed (no network)",
269
+ "update.apply": "Update now",
270
+ "update.updating": "Updating…",
271
+ "update.done": "Updated",
272
+ "update.restart": "restart DSH to load it",
273
+ "update.applyFailed": "Update failed — the previous version is intact",
242
274
  };
243
275
  //#endregion
244
276
 
@@ -708,6 +740,12 @@ window.__ModuleLoader__.load({
708
740
  /** Route the Host half serves for notification clicks; must match the Host half's FOCUS_PATH. */
709
741
  const FOCUS_PATH = "/notify-sounds/focus";
710
742
 
743
+ /** Route the Host half answers the update check on; must match the Host half's UPDATE_CHECK_PATH. */
744
+ const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
745
+
746
+ /** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
747
+ const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
748
+
711
749
  /**
712
750
  * Show one OS notification; every failure mode is silent. The tag
713
751
  * collapses repeats per trigger kind, and `silent` keeps the OS from
@@ -1039,6 +1077,21 @@ window.__ModuleLoader__.load({
1039
1077
  failed: failedField === "onlyWhenHidden",
1040
1078
  onChange: (next) => write("onlyWhenHidden", next),
1041
1079
  }),
1080
+ h(SwitchRow, {
1081
+ key: "updateAutoCheck",
1082
+ t,
1083
+ title: t("update.auto"),
1084
+ description: t("update.auto.description"),
1085
+ label: t("update.auto"),
1086
+ checked: value?.updateAutoCheck !== false,
1087
+ disabled,
1088
+ failed: failedField === "updateAutoCheck",
1089
+ onChange: (next) => write("updateAutoCheck", next),
1090
+ }),
1091
+ h(UpdateRow, {
1092
+ key: "update",
1093
+ t,
1094
+ }),
1042
1095
  ]),
1043
1096
  ]);
1044
1097
  }
@@ -1487,6 +1540,86 @@ window.__ModuleLoader__.load({
1487
1540
  ]),
1488
1541
  ]);
1489
1542
  }
1543
+
1544
+ /**
1545
+ * The update row: what this installation runs, what npm has, and the
1546
+ * buttons to ask and to install.
1547
+ *
1548
+ * The check is a read-only question; the update is one POST to the
1549
+ * Host, which does the downloading, verifying and swapping itself —
1550
+ * the page never touches the package. Both routes are fixed to this
1551
+ * plugin's own package, so there is no input here to aim anywhere
1552
+ * else, and the Host answers recent questions from its own cache, so
1553
+ * opening the section costs no network at all.
1554
+ *
1555
+ * @param props - copy.
1556
+ * @returns the row element tree.
1557
+ */
1558
+ function UpdateRow({ t }) {
1559
+ const [state, setState] = useState({ phase: "idle" });
1560
+ const check = (force) => async () => {
1561
+ setState({ phase: "checking" });
1562
+ try {
1563
+ const response = await fetch(UPDATE_CHECK_PATH + (force ? "?force=1" : ""), { method: "GET" });
1564
+ const payload = await response.json();
1565
+ setState({ phase: "done", payload });
1566
+ } catch {
1567
+ setState({ phase: "failed" });
1568
+ }
1569
+ };
1570
+ const apply = async () => {
1571
+ setState({ phase: "updating" });
1572
+ try {
1573
+ const response = await fetch(UPDATE_APPLY_PATH, { method: "POST" });
1574
+ const result = await response.json();
1575
+ setState({ phase: result?.ok === true ? (result.upToDate === true ? "done" : "updated") : "applyFailed", result });
1576
+ } catch {
1577
+ setState({ phase: "applyFailed" });
1578
+ }
1579
+ };
1580
+ // A cached answer makes this free; only a cold Host reaches out.
1581
+ useEffect(() => {
1582
+ check(false)();
1583
+ }, []);
1584
+ const payload = state.payload;
1585
+ const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1586
+ let status = payload === undefined
1587
+ ? t("update.idle")
1588
+ : payload.updateAvailable === true
1589
+ ? `${t("update.available")} ${version(payload.latest)}`
1590
+ : t("update.uptodate");
1591
+ if (state.phase === "checking") status = t("update.checking");
1592
+ if (state.phase === "updating") status = t("update.updating");
1593
+ if (state.phase === "applyFailed") status = t("update.applyFailed");
1594
+ let detail = payload === undefined ? status : `${t("update.current")} ${version(payload.current)} · ${status}`;
1595
+ if (state.phase === "updated") {
1596
+ detail = `${t("update.done")}:${version(state.result?.from)} → ${version(state.result?.to)},${t("update.restart")}`;
1597
+ }
1598
+ const busy = state.phase === "checking" || state.phase === "updating";
1599
+ return h("div", { className: css.row }, [
1600
+ h("div", { key: "text" }, [
1601
+ h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
1602
+ h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
1603
+ h("div", { className: css.hint, key: "status" }, detail),
1604
+ ]),
1605
+ h("div", { className: css.stackControl, key: "control" }, [
1606
+ payload?.updateAvailable === true && state.phase !== "updated" ? h(Button, {
1607
+ key: "apply",
1608
+ variant: "outline",
1609
+ size: "sm",
1610
+ disabled: busy,
1611
+ onClick: apply,
1612
+ }, t("update.apply")) : null,
1613
+ h(Button, {
1614
+ key: "check",
1615
+ variant: "outline",
1616
+ size: "sm",
1617
+ disabled: busy,
1618
+ onClick: check(true),
1619
+ }, t("update.check")),
1620
+ ]),
1621
+ ]);
1622
+ }
1490
1623
  //#endregion
1491
1624
 
1492
1625
  //#region settings nav icon
@@ -1991,6 +2124,32 @@ window.__ModuleLoader__.load({
1991
2124
  };
1992
2125
  }, "notify-sounds: session status watcher");
1993
2126
 
2127
+ // Quiet update check: shortly after the page settles, then at most
2128
+ // once a day, and never while the user has it turned off. It asks
2129
+ // the Host's own route — one small query, nothing downloaded — and
2130
+ // the answer is waiting in the settings row whenever the user looks.
2131
+ //
2132
+ // A self-rescheduling timeout rather than an interval: the next
2133
+ // check is scheduled after this one, so a suspended machine does not
2134
+ // come back to a queue of them. `unref` keeps a test process from
2135
+ // waiting on the next tick.
2136
+ ctx.effect(() => {
2137
+ let timer;
2138
+ const tick = () => {
2139
+ try {
2140
+ if (form.getSnapshot().value?.updateAutoCheck === false) return;
2141
+ fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2142
+ } catch {}
2143
+ timer = setTimeout(tick, 24 * 60 * 60 * 1000);
2144
+ timer.unref?.();
2145
+ };
2146
+ timer = setTimeout(tick, 30 * 1000);
2147
+ timer.unref?.();
2148
+ return () => {
2149
+ clearTimeout(timer);
2150
+ };
2151
+ }, "notify-sounds: update check");
2152
+
1994
2153
  // The Settings section: registered only while the Host serves this
1995
2154
  // package's namespace, so a deployment without the entry shows no
1996
2155
  // trace of the page. The nav glyph patch shares the gate — no
@@ -2029,6 +2188,8 @@ window.__ModuleLoader__.load({
2029
2188
  // value must survive.
2030
2189
  exports.SOUND_IDS = SOUND_IDS;
2031
2190
  exports.FOCUS_PATH = FOCUS_PATH;
2191
+ exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
2192
+ exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
2032
2193
  exports.timeOfDayToMinutes = timeOfDayToMinutes;
2033
2194
  return module.exports;
2034
2195
  },
package/lib/index.js CHANGED
@@ -23,9 +23,12 @@
23
23
 
24
24
  import z from "@deepseek-ai/schemastery";
25
25
  import { spawn } from "node:child_process";
26
+ import { createHash, timingSafeEqual } from "node:crypto";
26
27
  import { createRequire } from "node:module";
27
- import { existsSync } from "node:fs";
28
- import { dirname, join } from "node:path";
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { gunzipSync } from "node:zlib";
29
32
 
30
33
  /** Diagnostic name of this Host plugin. */
31
34
  export const name = "notify-sounds";
@@ -422,6 +425,435 @@ function focusDesktopWindow() {
422
425
  }
423
426
  }
424
427
 
428
+ //#region update check
429
+
430
+ /** Route the browser half reads the update check through. */
431
+ export const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
432
+
433
+ /** The npm package this plugin publishes as — the only name the check ever asks for. */
434
+ const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
435
+
436
+ /**
437
+ * Registries the update check consults, in order.
438
+ *
439
+ * HTTPS and hardcoded on purpose: this is the one place the plugin asks the
440
+ * outside world for something it may later act on, so nothing about it is
441
+ * configurable. The second entry is the mainland-China mirror, matching the
442
+ * install source DSH itself offers.
443
+ */
444
+ const UPDATE_REGISTRIES = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
445
+
446
+ /** How long one check result is trusted before the next check may run. */
447
+ const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
448
+
449
+ /** Minimum spacing between real requests, so a held-down button cannot hammer a registry. */
450
+ const UPDATE_CHECK_SPACING_MS = 15 * 1000;
451
+
452
+ /** Bounds on the single request a check makes. */
453
+ const UPDATE_FETCH_TIMEOUT_MS = 10 * 1000;
454
+ const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
455
+
456
+ /** Result of the last check, and when the last real request went out. */
457
+ const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined };
458
+
459
+ /**
460
+ * Parse `major.minor.patch` with an optional prerelease tag.
461
+ *
462
+ * @returns the parts, or undefined when the text is not a version.
463
+ */
464
+ export function parseVersion(text) {
465
+ if (typeof text !== "string") return undefined;
466
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(text);
467
+ if (match === null) return undefined;
468
+ return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
469
+ }
470
+
471
+ /**
472
+ * Compare two versions the way an npm `latest` tag is meant to be compared.
473
+ *
474
+ * @returns negative when `a` sorts before `b`, 0 when they are the same
475
+ * release, positive when `a` sorts after; `NaN` when either is not a version.
476
+ */
477
+ export function compareVersions(a, b) {
478
+ const left = parseVersion(a);
479
+ const right = parseVersion(b);
480
+ if (left === undefined || right === undefined) return NaN;
481
+ for (let i = 0; i < 3; i++) {
482
+ if (left[i] !== right[i]) return left[i] - right[i];
483
+ }
484
+ // Same core: a prerelease sorts before its own release (1.5.0-rc.1 < 1.5.0).
485
+ if (left[3] !== right[3]) {
486
+ if (left[3] === "") return 1;
487
+ if (right[3] === "") return -1;
488
+ return left[3] < right[3] ? -1 : 1;
489
+ }
490
+ return 0;
491
+ }
492
+
493
+ /**
494
+ * The version this installation ships, read from the package's own manifest.
495
+ *
496
+ * @returns the version, or undefined when the manifest is unreadable.
497
+ */
498
+ function readOwnVersion(root) {
499
+ try {
500
+ const manifest = root === undefined
501
+ ? new URL("../package.json", import.meta.url)
502
+ : join(root, "package.json");
503
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
504
+ return typeof parsed.version === "string" ? parsed.version : undefined;
505
+ } catch {
506
+ return undefined;
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Whether a tarball URL is somewhere this plugin is willing to download from.
512
+ *
513
+ * The URL arrives inside a registry response, so it is untrusted input: https
514
+ * only, and the host must be one of the registries above. A registry pointing
515
+ * anywhere else is refused rather than followed. The download stage checks
516
+ * this again on its own — validating it here too means a hostile answer is
517
+ * rejected at the door, not at the moment it is about to be used.
518
+ */
519
+ export function isTrustedTarball(url) {
520
+ try {
521
+ const parsed = new URL(url);
522
+ return parsed.protocol === "https:" && UPDATE_REGISTRIES.some((registry) => parsed.origin === registry);
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Read a response body under a hard size cap.
530
+ *
531
+ * The check asks for one small JSON document; anything longer is either not
532
+ * that document or is trying to be more than one, and neither is worth
533
+ * buffering. Chunks are counted as they arrive and the read is abandoned at
534
+ * the cap.
535
+ */
536
+ async function readCappedBytes(response, cap) {
537
+ const declared = Number(response.headers.get("content-length") ?? 0);
538
+ if (Number.isFinite(declared) && declared > cap) throw new Error("response too large");
539
+ const reader = response.body?.getReader?.();
540
+ if (reader === undefined) throw new Error("response has no body");
541
+ const chunks = [];
542
+ let size = 0;
543
+ for (;;) {
544
+ const { done, value } = await reader.read();
545
+ if (done) break;
546
+ size += value.byteLength;
547
+ if (size > cap) {
548
+ try {
549
+ await reader.cancel();
550
+ } catch {}
551
+ throw new Error("response too large");
552
+ }
553
+ chunks.push(value);
554
+ }
555
+ const merged = new Uint8Array(size);
556
+ let offset = 0;
557
+ for (const chunk of chunks) {
558
+ merged.set(chunk, offset);
559
+ offset += chunk.byteLength;
560
+ }
561
+ return merged;
562
+ }
563
+
564
+ /**
565
+ * Ask the registries for the newest published version of this plugin.
566
+ *
567
+ * Every failure mode — offline, refused, oversized, malformed, a tarball URL
568
+ * pointing somewhere else — becomes a result the UI can show, never a throw.
569
+ * This stage only reads: nothing is written to disk and nothing is executed.
570
+ *
571
+ * @param options - `force` asks again inside the freshness window (the
572
+ * button); otherwise a recent result is returned as-is.
573
+ * @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
574
+ * `{ ok: false, current, error }` when no registry answered.
575
+ */
576
+ export async function checkForUpdate(options = {}) {
577
+ const now = Date.now();
578
+ if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < UPDATE_CHECK_TTL_MS) {
579
+ return updateCheck.result;
580
+ }
581
+ // Spacing applies to real requests of every kind: a forced check may
582
+ // refresh, it may not become a load generator.
583
+ if (now - updateCheck.requestedAt < UPDATE_CHECK_SPACING_MS && updateCheck.result !== undefined) {
584
+ return updateCheck.result;
585
+ }
586
+ updateCheck.requestedAt = now;
587
+ const current = readOwnVersion();
588
+ const answer = await fetchRegistryAnswer();
589
+ if (answer === undefined) {
590
+ const result = { ok: false, current, error: "no registry answered" };
591
+ updateCheck.checkedAt = now;
592
+ updateCheck.result = result;
593
+ return result;
594
+ }
595
+ const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
596
+ const result = {
597
+ ok: true,
598
+ current,
599
+ latest: answer.version,
600
+ updateAvailable: Number.isFinite(ordering) && ordering < 0,
601
+ checkedAt: now,
602
+ };
603
+ updateCheck.checkedAt = now;
604
+ updateCheck.result = result;
605
+ return result;
606
+ }
607
+
608
+ /**
609
+ * Ask the registries for this plugin's newest release and believe only a
610
+ * well-formed answer.
611
+ *
612
+ * The response is untrusted input: the version must parse, the integrity must
613
+ * be a sha512 digest, and the tarball must live on one of the two registries.
614
+ * Anything else is skipped, so a hostile or broken answer is never the one a
615
+ * later download acts on. Both the check and the download go through here —
616
+ * the download never takes a URL from anywhere else.
617
+ *
618
+ * @returns `{ registry, version, tarball, integrity }`, or undefined when no
619
+ * registry produced an answer worth believing.
620
+ */
621
+ async function fetchRegistryAnswer() {
622
+ for (const registry of UPDATE_REGISTRIES) {
623
+ try {
624
+ log(`update check: asking ${registry}`);
625
+ const response = await fetch(`${registry}/${UPDATE_PACKAGE}/latest`, {
626
+ signal: AbortSignal.timeout(UPDATE_FETCH_TIMEOUT_MS),
627
+ redirect: "error",
628
+ headers: { accept: "application/json" },
629
+ });
630
+ if (!response.ok) continue;
631
+ const payload = JSON.parse(new TextDecoder().decode(await readCappedBytes(response, UPDATE_MAX_RESPONSE_BYTES)));
632
+ const version = payload?.version;
633
+ if (typeof version !== "string" || parseVersion(version) === undefined) continue;
634
+ const integrity = payload?.dist?.integrity;
635
+ const tarball = payload?.dist?.tarball;
636
+ if (typeof integrity !== "string" || !integrity.startsWith("sha512-")) continue;
637
+ if (typeof tarball !== "string" || !isTrustedTarball(tarball)) continue;
638
+ return { registry, version, tarball, integrity };
639
+ } catch {
640
+ // One registry failing is the ordinary case for the next one to
641
+ // answer; running out of registries is the failure worth reporting.
642
+ }
643
+ }
644
+ return undefined;
645
+ }
646
+
647
+ //#endregion
648
+
649
+ //#region update apply
650
+
651
+ /** Route the browser half asks for an update to be applied through. */
652
+ export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
653
+
654
+ /** Largest update tarball accepted: this plugin is small, and anything larger is not one. */
655
+ const UPDATE_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
656
+
657
+ /**
658
+ * The only files an update may replace, as package-relative paths.
659
+ *
660
+ * An update is a file swap, not an install: it replaces exactly what the
661
+ * package publishes and touches nothing else. The profile's own configuration
662
+ * lives outside this directory and is not on this list — no update can reach
663
+ * it, whatever an archive contains.
664
+ */
665
+ const UPDATE_FILES = ["package.json", "cordis.patch.yml", "README.md", "README.zh.md", "LICENSE", "lib/client.js", "lib/index.js"];
666
+
667
+ /** Where this package is installed: the only tree an update may write to. */
668
+ function packageRoot() {
669
+ return fileURLToPath(new URL("..", import.meta.url));
670
+ }
671
+
672
+ /** Read a NUL-terminated string field from a tar header. */
673
+ function readTarString(header, offset, length) {
674
+ const field = header.subarray(offset, offset + length);
675
+ let end = field.indexOf(0);
676
+ if (end === -1) end = field.length;
677
+ return new TextDecoder().decode(field.subarray(0, end)).trim();
678
+ }
679
+
680
+ /** Read an octal size field from a tar header; NaN when it is not one. */
681
+ function readTarOctal(header, offset, length) {
682
+ const text = readTarString(header, offset, length);
683
+ if (text === "") return 0;
684
+ const value = parseInt(text, 8);
685
+ return Number.isFinite(value) ? value : NaN;
686
+ }
687
+
688
+ /**
689
+ * Map an archive path to the package-relative file it may replace.
690
+ *
691
+ * Everything about a tar path is hostile until shown otherwise: only the
692
+ * `package/` prefix the registry publishes under, only the whitelisted names,
693
+ * and never a path that climbs, escapes, or arrives by another separator.
694
+ */
695
+ export function archivePathToRelative(path) {
696
+ const normalized = path.replaceAll("\\", "/");
697
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) throw new Error(`refusing tar path: ${path}`);
698
+ const prefix = "package/";
699
+ if (!normalized.startsWith(prefix)) throw new Error(`refusing tar path: ${path}`);
700
+ const relative = normalized.slice(prefix.length);
701
+ if (relative === "" || !UPDATE_FILES.includes(relative)) throw new Error(`refusing tar path: ${path}`);
702
+ return relative;
703
+ }
704
+
705
+ /**
706
+ * Read a tar archive into the few files an update may touch.
707
+ *
708
+ * The archive arrived from the network and is treated accordingly:
709
+ *
710
+ * - regular files only — a symlink in an update could point the next write
711
+ * anywhere, so any other entry type is refused outright;
712
+ * - pax/gnu extension headers are skipped, not interpreted;
713
+ * - every name must survive `archivePathToRelative`, so nothing outside
714
+ * this package is ever named;
715
+ * - a header claiming more than the archive holds is an error, not a read.
716
+ *
717
+ * @returns a Map of package-relative path → contents.
718
+ */
719
+ export function unpackTar(bytes) {
720
+ const files = new Map();
721
+ let offset = 0;
722
+ while (offset + 512 <= bytes.length) {
723
+ const header = bytes.subarray(offset, offset + 512);
724
+ const name = readTarString(header, 0, 100);
725
+ if (name === "") break;
726
+ const size = readTarOctal(header, 124, 12);
727
+ if (!Number.isFinite(size) || size < 0) throw new Error("tar entry has no readable size");
728
+ const contentStart = offset + 512;
729
+ if (contentStart + size > bytes.length) throw new Error("tar entry claims more than the archive holds");
730
+ const typeflag = readTarString(header, 156, 1);
731
+ const prefix = readTarString(header, 345, 155);
732
+ const content = bytes.slice(contentStart, contentStart + size);
733
+ offset = contentStart + Math.ceil(size / 512) * 512;
734
+ if (typeflag === "x" || typeflag === "g" || typeflag === "L") continue;
735
+ if (typeflag !== "0" && typeflag !== "") throw new Error(`refusing non-file tar entry (${typeflag})`);
736
+ files.set(archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`), content);
737
+ }
738
+ if (files.size === 0) throw new Error("the archive carried no usable files");
739
+ return files;
740
+ }
741
+
742
+ /**
743
+ * Check downloaded bytes against the digest the registry published.
744
+ *
745
+ * A mismatch is the one failure that must stop everything: whatever arrived is
746
+ * not the package the registry described, and not one byte of it is written.
747
+ */
748
+ export function verifyIntegrity(bytes, integrity) {
749
+ const expected = Buffer.from(String(integrity).slice("sha512-".length), "base64");
750
+ const actual = createHash("sha512").update(bytes).digest();
751
+ if (expected.length === 0 || expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
752
+ throw new Error("the tarball failed its sha512 check");
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Download the tarball a validated registry answer named.
758
+ *
759
+ * The URL was checked when the answer arrived and is checked again here; the
760
+ * response is capped and time-limited like every other request, and never
761
+ * redirected.
762
+ */
763
+ async function downloadTarball(url) {
764
+ if (!isTrustedTarball(url)) throw new Error("tarball URL is not a trusted registry");
765
+ log(`update: downloading ${url}`);
766
+ const response = await fetch(url, {
767
+ signal: AbortSignal.timeout(60 * 1000),
768
+ redirect: "error",
769
+ headers: { accept: "application/octet-stream" },
770
+ });
771
+ if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
772
+ return readCappedBytes(response, UPDATE_MAX_TARBALL_BYTES);
773
+ }
774
+
775
+ /**
776
+ * Swap the package's files for the update's, keeping the previous ones.
777
+ *
778
+ * Each file is written to a temporary name and renamed into place, so a
779
+ * failure cannot leave a half-written file under a real name. Everything the
780
+ * update is about to replace is copied aside first, and any failure puts it
781
+ * all back: a failed update is not a half-updated plugin.
782
+ */
783
+ export function installUpdate(root, files) {
784
+ const rootPrefix = resolve(root);
785
+ const backup = join(root, ".update-backup");
786
+ rmSync(backup, { recursive: true, force: true });
787
+ const written = [];
788
+ try {
789
+ for (const relative of UPDATE_FILES) {
790
+ const source = files.get(relative);
791
+ if (source === undefined) continue;
792
+ const target = resolve(root, relative);
793
+ if (target !== rootPrefix && !target.startsWith(rootPrefix)) throw new Error(`refusing to write outside the package: ${relative}`);
794
+ if (existsSync(target)) {
795
+ const aside = join(backup, relative);
796
+ mkdirSync(dirname(aside), { recursive: true });
797
+ writeFileSync(aside, readFileSync(target));
798
+ }
799
+ const temp = `${target}.update-tmp`;
800
+ writeFileSync(temp, source);
801
+ renameSync(temp, target);
802
+ written.push(relative);
803
+ }
804
+ } catch (error) {
805
+ for (const relative of written) {
806
+ const aside = join(backup, relative);
807
+ if (existsSync(aside)) writeFileSync(resolve(root, relative), readFileSync(aside));
808
+ }
809
+ throw error;
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Replace this installation with the newest published version.
815
+ *
816
+ * The stages are deliberate, and each one is a gate for the next: ask a
817
+ * registry (the answer is validated as strictly as for a check); download the
818
+ * tarball from the URL that answer named and nowhere else; verify its sha512
819
+ * against the digest the registry published — a mismatch stops here, before a
820
+ * byte is written; unpack it in memory, accepting only whitelisted regular
821
+ * files; then back up what is there and swap the files, restoring on failure.
822
+ *
823
+ * Nothing downloaded is ever executed here. The new code runs when DSH
824
+ * restarts, which is the same trust model as the user updating by hand.
825
+ *
826
+ * @param options - `root` overrides the package directory (for tests).
827
+ * @returns `{ ok: true, from, to, requiresRestart }`, `{ ok: true,
828
+ * upToDate: true, current }`, or `{ ok: false, stage, error }`.
829
+ */
830
+ export async function applyUpdate(options = {}) {
831
+ const root = options.root ?? packageRoot();
832
+ const current = readOwnVersion(root);
833
+ let stage = "check";
834
+ try {
835
+ const answer = await fetchRegistryAnswer();
836
+ if (answer === undefined) return { ok: false, stage, error: "no registry answered" };
837
+ const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
838
+ if (Number.isFinite(ordering) && ordering >= 0) return { ok: true, upToDate: true, current };
839
+ stage = "download";
840
+ const bytes = await downloadTarball(answer.tarball);
841
+ stage = "verify";
842
+ verifyIntegrity(bytes, answer.integrity);
843
+ stage = "unpack";
844
+ const files = unpackTar(gunzipSync(bytes));
845
+ stage = "replace";
846
+ installUpdate(root, files);
847
+ log(`update applied: ${current ?? "?"} -> ${answer.version}`);
848
+ return { ok: true, from: current, to: answer.version, requiresRestart: true };
849
+ } catch (error) {
850
+ warn(`update failed at ${stage}: ${error?.message ?? error}`);
851
+ return { ok: false, stage, error: error?.message ?? String(error) };
852
+ }
853
+ }
854
+
855
+ //#endregion
856
+
425
857
  /**
426
858
  * Live preferences projected to the browser. Every field is volatile: the
427
859
  * Settings document serves them, form edits reach them without restarting
@@ -440,6 +872,8 @@ export const Config = z.object({
440
872
  completionSound: z.union(SOUND_IDS).default("ding").volatile(),
441
873
  /** Also alert about sessions the agent spawned (sub-agents); off = only the user's own conversations. */
442
874
  subagentAlerts: z.boolean().default(false).volatile(),
875
+ /** Check npm for a new version on its own (at most one real check a day); off = only when the user asks. */
876
+ updateAutoCheck: z.boolean().default(true).volatile(),
443
877
  /** Play only while the page is not visible (background tab or window). */
444
878
  onlyWhenHidden: z.boolean().default(false).volatile(),
445
879
  /** Uploaded audio for the `custom` preset, as base64; empty when none. */
@@ -517,5 +951,72 @@ export function apply(ctx) {
517
951
  }
518
952
  },
519
953
  }), "notify-sounds: focus route");
954
+
955
+ // The update check route: a fixed, read-only question about this
956
+ // plugin's own npm package. Same fence as the focus route. It takes no
957
+ // URL, package name or version from the caller — there is nothing here
958
+ // to aim anywhere else.
959
+ child.effect(() => child.webServer.register({
960
+ kind: "exact",
961
+ path: UPDATE_CHECK_PATH,
962
+ handler: async (req, res) => {
963
+ const rejection = child.connection.requestRejection(req);
964
+ if (rejection !== undefined) {
965
+ res.statusCode = rejection;
966
+ res.end();
967
+ return;
968
+ }
969
+ if (req.method !== "GET") {
970
+ res.statusCode = 405;
971
+ res.setHeader("allow", "GET");
972
+ res.end();
973
+ return;
974
+ }
975
+ try {
976
+ const force = new URL(req.url ?? "/", "http://localhost").searchParams.get("force") === "1";
977
+ const result = await checkForUpdate({ force });
978
+ res.statusCode = 200;
979
+ res.setHeader("content-type", "application/json");
980
+ res.end(JSON.stringify(result));
981
+ } catch (error) {
982
+ warn(`update check failed: ${error?.message ?? error}`);
983
+ res.statusCode = 502;
984
+ res.end();
985
+ }
986
+ },
987
+ }), "notify-sounds: update check route");
988
+
989
+ // The update apply route: same fence, same fixed target. It accepts no
990
+ // version and no URL from the caller — the update it performs is always
991
+ // the one a registry described for this package, verified against that
992
+ // registry's own digest before anything is written.
993
+ child.effect(() => child.webServer.register({
994
+ kind: "exact",
995
+ path: UPDATE_APPLY_PATH,
996
+ handler: async (req, res) => {
997
+ const rejection = child.connection.requestRejection(req);
998
+ if (rejection !== undefined) {
999
+ res.statusCode = rejection;
1000
+ res.end();
1001
+ return;
1002
+ }
1003
+ if (req.method !== "POST") {
1004
+ res.statusCode = 405;
1005
+ res.setHeader("allow", "POST");
1006
+ res.end();
1007
+ return;
1008
+ }
1009
+ try {
1010
+ const result = await applyUpdate();
1011
+ res.statusCode = 200;
1012
+ res.setHeader("content-type", "application/json");
1013
+ res.end(JSON.stringify(result));
1014
+ } catch (error) {
1015
+ warn(`update apply failed: ${error?.message ?? error}`);
1016
+ res.statusCode = 500;
1017
+ res.end();
1018
+ }
1019
+ },
1020
+ }), "notify-sounds: update apply route");
520
1021
  });
521
1022
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-notify-sounds",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Notification sounds for DeepSeek Harness — a soft chime when the agent needs your decision or finishes a task, with a Settings section styled like DSH's own · 需要决策或任务完成时播放提示音,点击系统通知可回到窗口,设置面板可调音量与音效",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,7 +18,8 @@
18
18
  "lib/index.js",
19
19
  "lib/client.js",
20
20
  "cordis.patch.yml",
21
- "README.zh.md"
21
+ "README.zh.md",
22
+ "assets"
22
23
  ],
23
24
  "license": "MIT",
24
25
  "repository": {