dsh-plugin-notify-sounds 1.6.9 → 1.7.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/README.md CHANGED
@@ -34,7 +34,7 @@ Every visual detail of the settings section comes from DSH's own design system:
34
34
  | **Only what you care about** | Sub-agents (background tasks) are completely silent by default — only your own conversation is announced |
35
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 |
36
36
  | **Your sound, your way** | Nine built-in sounds, custom ringtone upload, volume — each feature with its own toggle |
37
- | **Update checks** | 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 |
37
+ | **Update checks** | Check npm for a new version and install it in one click: verified against the published digest before anything is replaced, restored on failure with advice you can act on; restart DSH to load it |
38
38
 
39
39
  ## 💡 Why
40
40
 
@@ -84,7 +84,13 @@ DSH does not update plugins on its own, but this plugin **updates itself**: Sett
84
84
  dsh plugin --profile <your profile> add github:BillCx330/dsh-plugin-notify-sounds
85
85
  ```
86
86
 
87
- **Release archive** — download the `.tgz` from [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) and hand it to `dsh plugin add`.
87
+ **Release archive** — download the `.tgz` from [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) and install it by its **path on disk**:
88
+
89
+ ```sh
90
+ dsh plugin --profile <your profile> add ./dsh-plugin-notify-sounds-1.7.0.tgz
91
+ ```
92
+
93
+ (`dsh plugin add` hands its argument straight to the package manager, so it takes a local file path — a download URL will not work.)
88
94
 
89
95
  **Local directory** — for development or trying unreleased changes:
90
96
 
@@ -99,7 +105,8 @@ The command line writes `dsh.profile.bundles` and installs the dependency for yo
99
105
  ## ⚠️ Known limitations
100
106
 
101
107
  - **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).
108
+ - **One-click update only writes files**: it writes the files the new release carries — added ones included — into this package's own directory and **never touches the profile's configuration**. It never *deletes*: a file a later release drops stays behind on machines that updated into it (harmless, but only a reinstall removes it). Downloaded code is never executed — the new code loads on a **DSH restart**. Let the row report a result before closing the window (a failed update restores itself, and says what to do next); if a future release adds a dependency, that needs installing separately (this one has none).
109
+ - **Versions up to 1.6.9 cannot update into a release that adds a file**: those builds carry a fixed list of seven filenames and refuse an archive containing anything else — **without damaging the installed version**, which keeps working as it did. Reinstalling once from npm or the repository's Releases fixes it permanently: 1.7.0 removed that limit, and from then on any release may add files.
103
110
  - **Multiple windows** fire independently: the same event may chime once per window; turn on notify-only-when-hidden to avoid it.
104
111
  - **Click-to-return** is fully implemented and tested on Windows; macOS / Linux fall back to the system deep link and are unverified.
105
112
  - **The window is found by its title** — deliberately without inspecting other processes (that trips security software). If DSH ever changes its title format, click-to-return silently stops working; sounds and notifications are unaffected.
@@ -124,6 +131,7 @@ For anyone hacking on the code (exact details live in the source comments):
124
131
 
125
132
  ## 📜 Changelog
126
133
 
134
+ - **v1.7.0** (2026-10-16) **The update mechanism turns a corner**: one-click update is no longer limited by a fixed list of seven filenames. It now accepts any regular file inside the package directory, governed by rules instead of an inventory — path traversal, absolute paths, symlinks, non-file entries, reserved names (its own backup/temp names and its record file, `.git`, `node_modules`, `.npmrc`), paths containing `:`, names colliding only by case, and a file that differs from the entry point `package.json` declares only by case are all still refused, with the file count and the decompressed size capped (enforced while inflating). **1.7.0 and later update cleanly into a release that adds or drops any number of files**: a file a later release drops is now actually deleted, based on the `.update-manifest.json` record this updater leaves behind — **only files an update installed are ever deleted**, anything you put in that directory yourself is left alone, and installations older than the first record are never guessed at. **1.6.9 and earlier will fail** (safely — nothing is written, the installed version keeps working) if a newer release adds a file, and the README says to reinstall once. Fixed: turning auto-check off ended the daily check chain for good, even after turning it back on; clicking a notification did nothing when the window title changed between the two Win32 reads; prerelease versions compared as strings (`rc.10` looked older than `rc.9`); **two tabs — or two windows — of the same GUI each rang the sound, raised their own system notification and ran their own repeat reminder** (one page is now elected to speak, via BroadcastChannel plus a shared claim, and the others stay silent until it disappears; per-window title marks are unaffected); and custom-sound normalization now visits **every sample** (taking every 8th frame aliased against content near fs/8 and systematically underestimated the peak). A failed update now gives **stage-appropriate advice** — retry for network failures, and the repository plus the uninstall/reinstall steps when the payload itself could not be applied, naming both versions — `package.json` is written **last** (a crash mid-swap leaves the old version number over the new code, so the next check still offers the update instead of declaring success), and the archive's manifest is verified to be this package at the version the registry named. The custom-sound row moved to sit **directly under the three trigger sounds** it is used by, and the smaller fixes on top: the cooldown table is a real LRU rather than FIFO (the session that fired most often was evicted first), one session-list snapshot per pass instead of per session, the macOS Desktop's window-title switch is now disabled rather than merely re-described, the full-width punctuation that leaked into the English update message is gone, and the README's release-archive instructions now give a command that actually works.
127
135
  - **v1.6.9** (2026-10-12) Fixes a README filename compatibility issue in v1.6.8: reverted to `README.md` (English) + `README.zh.md` (Chinese) so the update whitelist in every prior version can recognise them. A new filename would be refused by old users' updates — the whitelist is one-directional because the update runs old code.
128
136
  - **v1.6.8** (2026-10-12) Review fixes and optimizations: the auto-check first-frame timing gap is closed (`undefined` is no longer treated as permission to reach out); an orphaned JSDoc is removed; the teardown assertion now actually verifies listener removal; `normalizedGain` samples every 8th frame (2.6M → 330K iterations for a 30-second upload); the master gain disconnects after playback ends (no more audio-node leak); `swapNavIcon` is wrapped in try/catch and preserves the original node for restore on dispose (React re-renders can no longer crash it); the cooldown table evicts the oldest entry instead of clearing all; and `sessionRows` caches one snapshot per synchronous block.
129
137
  - **v1.6.7** (2026-10-12) Three fixes from the review: a macOS browser tab is no longer mislabeled "not supported" for the title alert (`isDesktopShell` is the right criterion, not `isMac` alone); the first-frame timing gap in the auto-check guard is closed (`undefined` is no longer treated as permission); and three comments that no longer matched the code are corrected. Plus BUG-1 regression tests covering the suspended AudioContext path.
package/README.zh.md CHANGED
@@ -34,7 +34,7 @@ dsh-plugin-notify-sounds
34
34
  | **只提醒你关心的** | 子智能体(后台任务)默认完全静默,只提醒主对话,可开关 |
35
35
  | **该安静时安静** | 静音时段(如 22:00–08:00,可跨午夜)、仅页面不可见时提示、总开关一键全静音 |
36
36
  | **声音随你** | 九种内置音效、自定义铃声上传、音量调节,每项独立开关 |
37
- | **检测更新** | 设置里检查 npm 新版本并一键更新:下载后校验完整性才替换,失败自动还原;更新后重启 DSH 生效 |
37
+ | **检测更新** | 设置里检查 npm 新版本并一键更新:下载后校验完整性才替换,失败自动还原并给出可照做的处理建议;更新后重启 DSH 生效 |
38
38
 
39
39
  ## 💡 为什么需要它
40
40
 
@@ -84,7 +84,13 @@ DSH 不会自动更新插件,但本插件**自带更新功能**:设置 →
84
84
  dsh plugin --profile <你的 profile> add github:BillCx330/dsh-plugin-notify-sounds
85
85
  ```
86
86
 
87
- **Release 压缩包** —— 从 [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) 下载 `.tgz`,交给 `dsh plugin add`。
87
+ **Release 压缩包** —— 从 [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) 下载 `.tgz`,用它**在磁盘上的路径**安装:
88
+
89
+ ```sh
90
+ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds-1.7.0.tgz
91
+ ```
92
+
93
+ (`dsh plugin add` 把参数原样交给包管理器,所以要给本地文件路径,不能填下载地址。)
88
94
 
89
95
  **本地目录** —— 开发或试用未发布的改动:
90
96
 
@@ -99,7 +105,8 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
99
105
  ## ⚠️ 已知边界
100
106
 
101
107
  - **手改配置**:偏好写在 profile 的 `cordis.patch.yml`,值必须符合 schema(音量 0–100、音效为预设之一、时间 `HH:MM`);写错会让插件不加载,改回即可。设置界面只会写入合法值。
102
- - **一键更新只换文件**:只替换本包内的七个文件,**不碰 profile 配置**;下载内容从不执行,新代码要等**重启 DSH** 才生效。更新过程请等状态行给出结果再关窗口(中断会自动还原);若未来的新版引入了新依赖,需另行安装(当前零依赖)。
108
+ - **一键更新只写文件**:把新版本带的文件写进本包目录(**新版新增的文件也会装上**),**不碰 profile 配置**;它只写不删——某个版本去掉的文件会留在更新过它的机器上(无害,但要重装才会彻底消失)。下载内容从不执行,新代码要等**重启 DSH** 才生效。更新过程请等状态行给出结果再关窗口(失败会自动还原,并告诉你下一步怎么做);若未来的新版引入了新依赖,需另行安装(当前零依赖)。
109
+ - **1.6.9 及更早版本无法更新到「新增了文件」的新版**:那些版本内置的是固定文件清单,遇到清单外的文件会整包拒绝——**不会损坏已装版本**,插件照常工作。从 npm 或仓库 Releases 手动重装一次即可永久解决:1.7.0 已取消该限制,之后任何版本都可以自由增减文件。
103
110
  - **多窗口**:各窗口独立触发,同一事件可能各响一次;打开「仅在页面不可见时提示」即可避免。
104
111
  - **点击通知回到窗口**:Windows 完整实现并实测;macOS / Linux 走系统深链,未验证。
105
112
  - **唤窗按窗口标题定位主窗口**:刻意不读取其他进程信息(那会触发杀软主动防御)。若将来 DSH 改变标题格式,唤窗会静默失效——提示音与通知本身不受影响。
@@ -123,6 +130,7 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
123
130
 
124
131
  ## 📜 更新记录
125
132
 
133
+ - **v1.7.0**(2026-10-16)**更新机制的转折点**:一键更新不再受「固定七个文件名」的白名单约束,改为按规则接受包目录下的任何普通文件——路径穿越、绝对路径、符号链接、非普通文件、保留名字(更新器自己的备份/临时名与记录文件、`.git`、`node_modules`、`.npmrc`)、含 `:` 的路径段,以及只有大小写不同的重名或**与 `package.json` 声明的入口只差大小写**的文件,依然一律拒绝,文件数与解压体积也都有上限(解压时即受 `gunzip` 上限约束)。**1.7.0 及以后可以正常更新到任意增减文件的新版**:某个版本去掉的文件现在会真的被删掉(依据更新器自己留下的 `.update-manifest.json` 记录,**只删它自己装过的文件**,用户放进目录里的东西一律不动;第一条记录之前的安装不做任何猜测)。**1.6.9 及更早**遇到「新增了文件」的新版会更新失败(安全失败:一个字节都不写,已装版本继续正常工作),README 里说明了重装一次的办法。修复:关掉「自动检查更新」会让每日轮询永久停摆(重新打开也不恢复);窗口标题在两次 Win32 读取之间变短时,点通知唤窗会静默失效;预发布版本号按字符串比较(`rc.10` 被误判为比 `rc.9` 旧);**同一个 GUI 开多个标签页或多个窗口时提示音、系统通知、重复提醒全部翻倍**(现在由 BroadcastChannel + 本地记录选出一个「发声页」,其余页保持安静,只在发声页消失后接管;窗口标题标记各窗口独立,不受影响);自定义音效的峰值归一化改为**逐样本**扫描(原先每 8 帧抽样会与接近 fs/8 的高频混叠、系统性低估峰值)。更新失败时给出**按阶段区分的建议**(网络类提示重试;解包/替换类给出手动重装步骤与仓库地址,并列出失败的两个版本号),写入顺序改为**最后写 `package.json`**(中途崩溃时检查仍会提示更新,用户再点一次即可自愈),并会校验归档里的清单确实是本包、确实是 registry 报的那个版本。设置里「自定义音效」移到**三种提示音之后**(它们才是会用到它的三处);`settings` 相关的小修:冷却表改为真正的 LRU(原先是 FIFO,最常触发的会话反而最先被淘汰)、会话列表每轮只取一次快照、macOS 桌面版的「窗口标题提醒」开关改为**禁用**(原先能打开但标记无处显示)、英文界面更新提示里残留的全角标点修掉、README 的 Release 安装说明改为可照做的本地路径写法。
126
134
  - **v1.6.9**(2026-10-12)修正 v1.6.8 的 README 文件名兼容性:改回 `README.md`(英文默认)+ `README.zh.md`(中文),确保旧版本更新白名单能识别。新文件名会让旧用户的更新被拒——白名单是单向兼容的,更新时跑的是旧代码。
127
135
  - **v1.6.8**(2026-10-12)修复复核细节 + 优化:`autoCheck` 首帧时序补上(`undefined` 不再被当成允许联网);删孤立 JSDoc;teardown 断言改为真正验证监听器移除;`normalizedGain` 抽样(每 8 帧,260 万→33 万次);`master` gain 播完自动断连(不再泄漏音频节点);`swapNavIcon` try/catch + 保留原节点、dispose 时还原(React 重排不再炸);冷却表 LRU 淘汰(不再全清);`sessionRows` 同步块级快照缓存。
128
136
  - **v1.6.7**(2026-10-12)修复复核发现的 3 处细节:macOS 浏览器标签页不再被误标「暂不支持标题提醒」(`isDesktopShell` 才是判据);「自动检查=关」的首帧时序缺口补上(`undefined` 不再被当成允许);三处注释与实现不符已更正。补 BUG-1 回归测试(挂起 AudioContext 三断言)。
package/lib/client.js CHANGED
@@ -97,6 +97,18 @@ window.__ModuleLoader__.load({
97
97
  */
98
98
  const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
99
99
 
100
+ /**
101
+ * Whether the window-title mark has anywhere to appear.
102
+ *
103
+ * Only the macOS Desktop shell fails this, for the reason above. The
104
+ * row's switch is DISABLED rather than merely re-described: an enabled
105
+ * switch that the user can turn on and that then never does anything is
106
+ * worse than one that says it is not available yet — the old copy read
107
+ * like a working feature with a caveat, so people turned it on and
108
+ * waited.
109
+ */
110
+ const titleFlashUnavailable = () => isMac() && isDesktopShell();
111
+
100
112
  /** Stylesheet class names, one projection the components read. */
101
113
  const css = {
102
114
  section: "dsh-notify-sounds-section",
@@ -178,7 +190,7 @@ window.__ModuleLoader__.load({
178
190
  "systemNotify.unsupported": "当前环境不支持系统通知",
179
191
  "titleFlash.title": "窗口标题提醒",
180
192
  "titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
181
- "titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
193
+ "titleFlash.macUnsupported": "macOS 暂不支持,开关已禁用:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
182
194
  "titleFlash.pending": "待处理",
183
195
  "titleFlash.done": "已完成",
184
196
  "renotify.title": "未处理决策重复提醒",
@@ -220,9 +232,17 @@ window.__ModuleLoader__.load({
220
232
  "update.failed": "检查失败(网络不可用)",
221
233
  "update.apply": "立即更新",
222
234
  "update.updating": "更新中…",
223
- "update.done": "更新完成",
224
- "update.restart": "重启 DSH 后生效",
235
+ "update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
225
236
  "update.applyFailed": "更新失败({stage}),已恢复原状",
237
+ "update.stage.check": "检查",
238
+ "update.stage.download": "下载",
239
+ "update.stage.verify": "校验",
240
+ "update.stage.unpack": "解包",
241
+ "update.stage.manifest": "核对包信息",
242
+ "update.stage.replace": "替换文件",
243
+ "update.stage.unknown": "未知阶段",
244
+ "update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
245
+ "update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
226
246
  };
227
247
 
228
248
  /** English dictionary, checked complete against the zh key set. */
@@ -257,7 +277,7 @@ window.__ModuleLoader__.load({
257
277
  "systemNotify.unsupported": "System notifications are unavailable in this environment",
258
278
  "titleFlash.title": "Window title alert",
259
279
  "titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
260
- "titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
280
+ "titleFlash.macUnsupported": "Not supported on macOS, so this switch is disabled: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
261
281
  "titleFlash.pending": "Waiting for you",
262
282
  "titleFlash.done": "Finished",
263
283
  "renotify.title": "Repeat unanswered decisions",
@@ -299,9 +319,17 @@ window.__ModuleLoader__.load({
299
319
  "update.failed": "Check failed (no network)",
300
320
  "update.apply": "Update now",
301
321
  "update.updating": "Updating…",
302
- "update.done": "Updated",
303
- "update.restart": "restart DSH to load it",
322
+ "update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
304
323
  "update.applyFailed": "Update failed ({stage}) — the previous version is intact",
324
+ "update.stage.check": "check",
325
+ "update.stage.download": "download",
326
+ "update.stage.verify": "checksum",
327
+ "update.stage.unpack": "unpack",
328
+ "update.stage.manifest": "package check",
329
+ "update.stage.replace": "file replacement",
330
+ "update.stage.unknown": "unknown step",
331
+ "update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
332
+ "update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
305
333
  };
306
334
  //#endregion
307
335
 
@@ -443,19 +471,27 @@ window.__ModuleLoader__.load({
443
471
  * Peak-normalization gain for one decoded buffer: scales the loudest
444
472
  * sample toward full scale, boosting at most 4× so near-silent files
445
473
  * do not get blown up.
474
+ *
475
+ * Every sample is visited, deliberately. An earlier version stepped
476
+ * through the buffer taking every 8th frame and claimed the peak would
477
+ * be caught anyway because "neighbouring samples carry the same
478
+ * magnitude" — that only holds for a heavily oversampled signal. At
479
+ * 44.1 kHz a 10 kHz component has about 4.4 samples per period, so a
480
+ * stride of 8 aliases against it and can systematically miss the real
481
+ * peak; a tone near fs/8 is the extreme case. An underestimated peak
482
+ * means a boost that is too large, so the custom sound plays hot and
483
+ * can clip on its loudest transient.
484
+ *
485
+ * The cost is not on a playback path: this runs once per uploaded file,
486
+ * right after `decodeAudioData` (which costs far more), and the result
487
+ * is cached alongside the buffer in `customSoundCache`.
446
488
  */
447
489
  function normalizedGain(buffer) {
448
490
  try {
449
491
  let peak = 0;
450
- // Sample every 8th frame: a peak that would clip is still
451
- // caught by any reasonably strided scan (audio waveforms are
452
- // smooth enough that adjacent samples carry the same
453
- // magnitude), and this keeps a 30-second upload off the main
454
- // thread's critical path.
455
- const stride = 8;
456
492
  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
457
493
  const data = buffer.getChannelData(channel);
458
- for (let i = 0; i < data.length; i += stride) {
494
+ for (let i = 0; i < data.length; i++) {
459
495
  const magnitude = Math.abs(data[i]);
460
496
  if (magnitude > peak) peak = magnitude;
461
497
  }
@@ -574,11 +610,25 @@ window.__ModuleLoader__.load({
574
610
  // Release the master gain once the tail has faded. Without
575
611
  // this, every play leaves a gain node permanently wired to
576
612
  // the destination — a slow audio-node leak on long sessions.
577
- setTimeout(() => {
613
+ const remaining = () => (t0 + tail + 0.1 - engine.currentTime) * 1000;
614
+ const release = (rechecked) => {
578
615
  try {
616
+ // The delay was computed when this was scheduled, and a
617
+ // suspended context freezes `currentTime`: without this
618
+ // second look, a timer that comes due during a freeze
619
+ // disconnects a tail that is still playing. Re-check
620
+ // once — not in a loop, so a clock that never advances
621
+ // (a test's frozen mock, a context suspended for good)
622
+ // still ends with the node released.
623
+ if (!rechecked && remaining() > 5) {
624
+ setTimeout(() => release(true), remaining() + 50);
625
+ return;
626
+ }
579
627
  master.disconnect();
580
628
  } catch {}
581
- }, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
629
+ };
630
+ const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
631
+ timer.unref?.();
582
632
  });
583
633
  }
584
634
 
@@ -817,6 +867,183 @@ window.__ModuleLoader__.load({
817
867
  /** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
818
868
  const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
819
869
 
870
+ /**
871
+ * Where a user goes when the one-click update cannot finish.
872
+ *
873
+ * Shown as plain selectable text rather than a link: the Desktop shell
874
+ * is a `dsh-app://` page and the Settings primitives expose no external
875
+ * link component, so a URL the user can copy is the honest form — a
876
+ * button that silently did nothing here would be worse than no button.
877
+ */
878
+ const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
879
+
880
+ /**
881
+ * Every stage the Host can report, and whether a fresh install is the remedy.
882
+ *
883
+ * Keyed off the stage rather than off the message: telling someone whose
884
+ * download timed out to uninstall the plugin would be actively bad
885
+ * advice, and telling someone whose archive was rejected to "try again
886
+ * later" leaves them with no way forward at all. `true` means the
887
+ * payload itself could not be applied, which retrying will not change.
888
+ */
889
+ const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
890
+
891
+ /** The localized word for a Host-side update stage. */
892
+ const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
893
+
894
+ /** `v1.2.3` for display, `?` when the Host reported no version. */
895
+ const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
896
+
897
+ /**
898
+ * The line reporting an installation that landed.
899
+ *
900
+ * Composed here rather than in JSX so the notice above the section and
901
+ * the row inside it say exactly the same thing, and so the whole
902
+ * sentence — punctuation included — lives in the dictionaries instead
903
+ * of being assembled from fragments at the call site (which is how the
904
+ * English UI ended up with full-width stops).
905
+ */
906
+ const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
907
+
908
+ /**
909
+ * The two lines a failed update shows: what happened, and what to do.
910
+ *
911
+ * The remedy is chosen from the stage the Host reported, never from the
912
+ * message: a download that timed out must not be answered with "uninstall
913
+ * and reinstall", and an archive that was refused must not be answered
914
+ * with "try again later".
915
+ */
916
+ const updateFailureText = (t, failed, current) => {
917
+ // Name both versions whenever the target is known: "the update
918
+ // failed" on its own cannot be acted on afterwards, and this is the
919
+ // line a user copies into a bug report.
920
+ const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
921
+ return {
922
+ detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
923
+ advice: UPDATE_STAGES[failed?.stage] === true
924
+ ? t("update.reinstall", { url: REPOSITORY_URL })
925
+ : t("update.retry", { url: REPOSITORY_URL }),
926
+ };
927
+ };
928
+
929
+ /** Channel name and claim key shared by every page of this origin. */
930
+ const ALERT_CHANNEL = "dsh-notify-sounds:alerts";
931
+ const ALERT_LEADER_KEY = "dsh-notify-sounds:leader";
932
+ /** How often the leader re-asserts, and when a quiet claim is considered abandoned. */
933
+ const ALERT_HEARTBEAT_MS = 5000;
934
+ const ALERT_STALE_MS = 15000;
935
+
936
+ /**
937
+ * `localStorage`, when this page has one that answers.
938
+ *
939
+ * Even reading it can throw where storage is blocked (private mode, a
940
+ * sandboxed frame), and a page without it simply leads itself.
941
+ *
942
+ * @returns the storage, or undefined.
943
+ */
944
+ const safeAlertStorage = () => {
945
+ try {
946
+ if (typeof localStorage === "undefined") return undefined;
947
+ localStorage.getItem(ALERT_LEADER_KEY);
948
+ return localStorage;
949
+ } catch {
950
+ return undefined;
951
+ }
952
+ };
953
+
954
+ /**
955
+ * Elect one page among the tabs — and windows — of this GUI to be the one
956
+ * that speaks.
957
+ *
958
+ * Two pages are two independent subscribers to the same session facts:
959
+ * left alone, one decision rings the sound twice, raises two system
960
+ * notifications and runs two repeat reminders (the settings even say
961
+ * "at most one quiet check a day" while both of them check). The election
962
+ * is a claim in `localStorage`, which every page of this origin shares,
963
+ * refreshed by a heartbeat; a claim that stops being refreshed is taken
964
+ * over, so a closed, crashed or frozen tab hands the role on within a few
965
+ * seconds. `BroadcastChannel` only makes a claim heard sooner — the
966
+ * correctness rests on the stored claim, because whoever wrote it last
967
+ * owns the role and every page re-reads it rather than trusting a message.
968
+ *
969
+ * The title mark is deliberately NOT gated by this: each window and each
970
+ * tab has its own title, so marking them all is right, while a sound and
971
+ * a toast belong to the machine.
972
+ *
973
+ * Pages that can use neither mechanism — the smoke harness, a hardened
974
+ * browser context — lead themselves, which is the behaviour before this
975
+ * existed.
976
+ *
977
+ * @returns `{ isLeader, refresh, dispose }`, where `refresh` re-runs the
978
+ * election (the tests drive it instead of waiting for a heartbeat).
979
+ */
980
+ function createAlertLeader() {
981
+ const storage = safeAlertStorage();
982
+ const Channel = typeof BroadcastChannel === "function" ? BroadcastChannel : undefined;
983
+ if (storage === undefined || Channel === undefined) {
984
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
985
+ }
986
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
987
+ let channel;
988
+ try {
989
+ channel = new Channel(ALERT_CHANNEL);
990
+ } catch {
991
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
992
+ }
993
+ let leader = false;
994
+ const readClaim = () => {
995
+ try {
996
+ const parsed = JSON.parse(storage.getItem(ALERT_LEADER_KEY) ?? "null");
997
+ return parsed !== null && typeof parsed?.id === "string" && Number.isFinite(parsed?.at) ? parsed : undefined;
998
+ } catch {
999
+ return undefined;
1000
+ }
1001
+ };
1002
+ const claim = () => {
1003
+ leader = true;
1004
+ try {
1005
+ storage.setItem(ALERT_LEADER_KEY, JSON.stringify({ id, at: Date.now() }));
1006
+ } catch {}
1007
+ try {
1008
+ channel.postMessage({ id });
1009
+ } catch {}
1010
+ };
1011
+ const refresh = () => {
1012
+ const current = readClaim();
1013
+ if (current === undefined || current.id === id) {
1014
+ claim();
1015
+ return;
1016
+ }
1017
+ // Somebody else holds a claim. While it is fresh this page stays
1018
+ // silent; once it goes stale its holder is gone and this page
1019
+ // takes over.
1020
+ if (Date.now() - current.at < ALERT_STALE_MS) {
1021
+ leader = false;
1022
+ return;
1023
+ }
1024
+ claim();
1025
+ };
1026
+ channel.onmessage = (event) => {
1027
+ if (event?.data?.id !== id) refresh();
1028
+ };
1029
+ const timer = setInterval(refresh, ALERT_HEARTBEAT_MS);
1030
+ timer.unref?.();
1031
+ refresh();
1032
+ return {
1033
+ isLeader: () => leader,
1034
+ refresh,
1035
+ dispose: () => {
1036
+ clearInterval(timer);
1037
+ try {
1038
+ if (leader && readClaim()?.id === id) storage.removeItem(ALERT_LEADER_KEY);
1039
+ } catch {}
1040
+ try {
1041
+ channel.close();
1042
+ } catch {}
1043
+ },
1044
+ };
1045
+ }
1046
+
820
1047
  /**
821
1048
  * Show one OS notification; every failure mode is silent.
822
1049
  *
@@ -981,8 +1208,7 @@ window.__ModuleLoader__.load({
981
1208
  .catch(() => {});
982
1209
  }, [autoCheck]);
983
1210
  if (applied === undefined || typeof applied.to !== "string") return null;
984
- const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
985
- return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
1211
+ return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
986
1212
  }
987
1213
 
988
1214
  function NotifySoundsSection({ useConfig, setField, preview, t }) {
@@ -1099,17 +1325,6 @@ window.__ModuleLoader__.load({
1099
1325
  },
1100
1326
  onPreview: (id) => preview(id, volume),
1101
1327
  }),
1102
- h(SwitchRow, {
1103
- key: "subagent",
1104
- t,
1105
- title: t("subagent.title"),
1106
- description: t("subagent.description"),
1107
- label: t("subagent.title"),
1108
- checked: value?.subagentAlerts === true,
1109
- disabled,
1110
- failed: failedField === "subagentAlerts",
1111
- onChange: (next) => write("subagentAlerts", next),
1112
- }),
1113
1328
  h(CustomSoundRow, {
1114
1329
  key: "custom",
1115
1330
  t,
@@ -1152,6 +1367,17 @@ window.__ModuleLoader__.load({
1152
1367
  },
1153
1368
  onPreview: () => preview("custom", volume),
1154
1369
  }),
1370
+ h(SwitchRow, {
1371
+ key: "subagent",
1372
+ t,
1373
+ title: t("subagent.title"),
1374
+ description: t("subagent.description"),
1375
+ label: t("subagent.title"),
1376
+ checked: value?.subagentAlerts === true,
1377
+ disabled,
1378
+ failed: failedField === "subagentAlerts",
1379
+ onChange: (next) => write("subagentAlerts", next),
1380
+ }),
1155
1381
  h(SystemNotifyRow, {
1156
1382
  key: "systemNotify",
1157
1383
  t,
@@ -1170,10 +1396,10 @@ window.__ModuleLoader__.load({
1170
1396
  // document title like any other, so this is not
1171
1397
  // applied there. iOS Safari's UA also contains "like
1172
1398
  // Mac OS X" — `isDesktopShell` keeps it out.
1173
- description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1399
+ description: titleFlashUnavailable() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1174
1400
  label: t("titleFlash.title"),
1175
1401
  checked: value?.titleFlash === true,
1176
- disabled,
1402
+ disabled: disabled || titleFlashUnavailable(),
1177
1403
  failed: failedField === "titleFlash",
1178
1404
  onChange: (next) => write("titleFlash", next),
1179
1405
  }),
@@ -1728,7 +1954,6 @@ window.__ModuleLoader__.load({
1728
1954
  check(false)();
1729
1955
  }, [autoCheck]);
1730
1956
  const payload = state.payload;
1731
- const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1732
1957
  // The Host remembers the last install and repeats it with every
1733
1958
  // answer, and this row prefers that: an update swaps the client
1734
1959
  // half's own file, so the page may reload and drop this row's
@@ -1740,15 +1965,21 @@ window.__ModuleLoader__.load({
1740
1965
  let status = payload === undefined
1741
1966
  ? t("update.idle")
1742
1967
  : payload.updateAvailable === true
1743
- ? `${t("update.available")} ${version(payload.latest)}`
1968
+ ? `${t("update.available")} ${formatVersion(payload.latest)}`
1744
1969
  : t("update.uptodate");
1745
1970
  if (state.phase === "checking") status = t("update.checking");
1746
1971
  if (state.phase === "updating") status = t("update.updating");
1747
- let detail = payload === undefined ? status : `${t("update.current")} ${version(payload.current)} · ${status}`;
1972
+ let detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
1973
+ // Advice is a second line rather than more words in the first one:
1974
+ // the failure line is the part a user scans, and the remedy only
1975
+ // applies to some stages. `undefined` renders nothing at all.
1976
+ let advice;
1748
1977
  if (appliedDone) {
1749
- detail = `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`;
1978
+ detail = updateAppliedText(t, applied);
1750
1979
  } else if (state.phase === "applyFailed") {
1751
- detail = t("update.applyFailed", { stage: String(state.result?.stage ?? "?") });
1980
+ const failure = updateFailureText(t, state.result, payload?.current);
1981
+ detail = failure.detail;
1982
+ advice = failure.advice;
1752
1983
  }
1753
1984
  const busy = state.phase === "checking" || state.phase === "updating";
1754
1985
  return h("div", { className: css.row }, [
@@ -1756,6 +1987,7 @@ window.__ModuleLoader__.load({
1756
1987
  h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
1757
1988
  h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
1758
1989
  h("div", { className: css.hint, key: "status" }, detail),
1990
+ advice === undefined ? null : h("div", { className: css.hint, key: "advice" }, advice),
1759
1991
  ]),
1760
1992
  h("div", { className: css.stackControl, key: "control" }, [
1761
1993
  payload?.updateAvailable === true && !appliedDone ? h(Button, {
@@ -1893,6 +2125,12 @@ window.__ModuleLoader__.load({
1893
2125
  let panelObserver = null;
1894
2126
  let observedPanel = null;
1895
2127
  const scan = (root) => {
2128
+ // One panel-level probe before the walk. This observer fires on
2129
+ // every change anywhere in the settings tree — a conversation
2130
+ // streaming next to an open panel included — and once this
2131
+ // section's cell carries the glyph there is nothing left to do;
2132
+ // the per-cell checks below would only re-establish that.
2133
+ if (root.querySelector(`[${NAV_ICON_MARKER}]`) !== null) return;
1896
2134
  const label = t("nav");
1897
2135
  for (const cell of root.querySelectorAll("button")) swapNavIcon(cell, label);
1898
2136
  };
@@ -1961,6 +2199,11 @@ window.__ModuleLoader__.load({
1961
2199
  */
1962
2200
  function apply(ctx) {
1963
2201
  insertStyles();
2202
+ // One page per browser profile speaks (see createAlertLeader): a
2203
+ // sound and a toast belong to the machine, and a second tab of the
2204
+ // same GUI must not raise them a second time.
2205
+ const alertLeader = createAlertLeader();
2206
+ ctx.effect(() => alertLeader.dispose, "notify-sounds: alert leader");
1964
2207
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "notify-sounds: dictionaries");
1965
2208
  const t = ctx.locale.bind(NS);
1966
2209
  // The platform's own resolver for package text, which may be a plain
@@ -1995,7 +2238,8 @@ window.__ModuleLoader__.load({
1995
2238
  const cooldown = new Map();
1996
2239
 
1997
2240
  /**
1998
- * The session list rows, if this runtime exposes them.
2241
+ * One snapshot of the session list rows, or `undefined` when this
2242
+ * runtime exposes none.
1999
2243
  *
2000
2244
  * The status facts carry no classification at all — an entry is
2001
2245
  * only `{ running, pendingInteraction, completionUnread }` — so
@@ -2003,24 +2247,20 @@ window.__ModuleLoader__.load({
2003
2247
  * session list, whose rows carry the same `origin`/`parentId` the
2004
2248
  * sidebar uses to keep sub-agents out of the ordinary list.
2005
2249
  *
2250
+ * Callers read ONCE per pass and hand the result down: the previous
2251
+ * shape called this per session inside the loops below, so a status
2252
+ * update covering ten sessions took ten snapshots, and the pass
2253
+ * scope was implied by "the same millisecond" rather than stated.
2254
+ *
2006
2255
  * @returns the `byId` row map, or undefined when unavailable.
2007
2256
  */
2008
- // Cache one snapshot per synchronous block: `isNotifiable` is
2009
- // called per session in read()/trackPending loops, and each call
2010
- // would otherwise take a fresh snapshot (O8).
2011
- let cachedRows;
2012
- let cachedRowsAt = 0;
2013
- const sessionRows = () => {
2014
- const now = Date.now();
2015
- if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
2257
+ const readSessionRows = () => {
2016
2258
  try {
2017
- cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
2259
+ return ctx.sessions?.list?.getSnapshot?.()?.byId
2018
2260
  ?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
2019
2261
  } catch {
2020
- cachedRows = undefined;
2262
+ return undefined;
2021
2263
  }
2022
- cachedRowsAt = now;
2023
- return cachedRows;
2024
2264
  };
2025
2265
 
2026
2266
  /**
@@ -2028,10 +2268,11 @@ window.__ModuleLoader__.load({
2028
2268
  * sub-agent, a teammate — rather than one of the user's own.
2029
2269
  *
2030
2270
  * @param sessionId - the session to classify.
2271
+ * @param rows - the pass's session rows, when the runtime has them.
2031
2272
  * @returns whether it is a spawned child session.
2032
2273
  */
2033
- const isSubagentSession = (sessionId) => {
2034
- const row = sessionRows()?.[sessionId];
2274
+ const isSubagentSession = (sessionId, rows) => {
2275
+ const row = rows?.[sessionId];
2035
2276
  // Deliberately ONLY `origin`, the same test the sidebar and the
2036
2277
  // sub-agent UI use. `parentId` alone is not enough: a forked
2037
2278
  // conversation carries one too and is still the user's own.
@@ -2048,12 +2289,13 @@ window.__ModuleLoader__.load({
2048
2289
  * not something they want a chime for.
2049
2290
  *
2050
2291
  * @param sessionId - the session to test.
2292
+ * @param rows - the pass's session rows, when the runtime has them.
2051
2293
  * @returns whether its events should alert.
2052
2294
  */
2053
- const isNotifiable = (sessionId) => {
2295
+ const isNotifiable = (sessionId, rows) => {
2054
2296
  const value = form.getSnapshot().value;
2055
2297
  if (value?.subagentAlerts === true) return true;
2056
- return !isSubagentSession(sessionId);
2298
+ return !isSubagentSession(sessionId, rows);
2057
2299
  };
2058
2300
 
2059
2301
  /**
@@ -2075,6 +2317,10 @@ window.__ModuleLoader__.load({
2075
2317
  * reminder is not recorded as sent.
2076
2318
  */
2077
2319
  const notify = (kind, interaction, sessionId, options = {}) => {
2320
+ // The elected page is the only one that makes a noise or raises
2321
+ // a notification; every page still keeps its own title mark,
2322
+ // because a title belongs to its own window.
2323
+ if (!alertLeader.isLeader()) return false;
2078
2324
  const value = form.getSnapshot().value;
2079
2325
  if (value?.enabled === false) return false;
2080
2326
  if (value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd)) return false;
@@ -2096,8 +2342,17 @@ window.__ModuleLoader__.load({
2096
2342
  // Evict the oldest entry rather than clearing the whole
2097
2343
  // table: one session hitting the cap must not silence
2098
2344
  // every other session's cooldown.
2099
- if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2100
- cooldown.set(`${kind}:${sessionId}`, now);
2345
+ //
2346
+ // LRU, not FIFO: `Map.set` on a key that is already there
2347
+ // updates the value and KEEPS the original position, so
2348
+ // plain eviction would throw away whichever entry is
2349
+ // touched most often — precisely the session whose
2350
+ // two-second dedupe is doing work. Deleting first moves it
2351
+ // to the back on re-insertion.
2352
+ const key = `${kind}:${sessionId}`;
2353
+ if (cooldown.has(key)) cooldown.delete(key);
2354
+ else if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2355
+ cooldown.set(key, now);
2101
2356
  }
2102
2357
  const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
2103
2358
  let id = typeof value?.[kind + "Sound"] === "string" ? value[kind + "Sound"] : fallback;
@@ -2156,9 +2411,9 @@ window.__ModuleLoader__.load({
2156
2411
  return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
2157
2412
  };
2158
2413
  /** Whether any session is currently running. */
2159
- const anyRunning = () => {
2414
+ const anyRunning = (rows) => {
2160
2415
  for (const [sessionId, status] of source.getSnapshot()) {
2161
- if (status.running === true && isNotifiable(sessionId)) return true;
2416
+ if (status.running === true && isNotifiable(sessionId, rows)) return true;
2162
2417
  }
2163
2418
  return false;
2164
2419
  };
@@ -2180,7 +2435,7 @@ window.__ModuleLoader__.load({
2180
2435
  // the quiet window ends.
2181
2436
  if (record.quiet !== true) return "pending";
2182
2437
  }
2183
- if (anyRunning()) return undefined;
2438
+ if (anyRunning(readSessionRows())) return undefined;
2184
2439
  return unreadCompletion ? "done" : undefined;
2185
2440
  };
2186
2441
  /** Refresh the title alert from the current facts. */
@@ -2190,12 +2445,12 @@ window.__ModuleLoader__.load({
2190
2445
  applyTitleFlash(wantedTitleState(), titleAlerts(t));
2191
2446
  };
2192
2447
  /** Rebuild the tracker from one snapshot, then refresh the flash. */
2193
- const trackPending = (snapshot) => {
2448
+ const trackPending = (snapshot, rows) => {
2194
2449
  const next = new Map();
2195
2450
  for (const [sessionId, status] of snapshot) {
2196
2451
  // A filtered session is invisible to the whole package:
2197
2452
  // it gets no reminder and no title mark either.
2198
- if (!isNotifiable(sessionId)) continue;
2453
+ if (!isNotifiable(sessionId, rows)) continue;
2199
2454
  const interaction = status.pendingInteraction;
2200
2455
  if (interaction === undefined) continue;
2201
2456
  const record = pending.get(sessionId);
@@ -2222,11 +2477,14 @@ window.__ModuleLoader__.load({
2222
2477
  };
2223
2478
  const read = () => {
2224
2479
  const snapshot = source.getSnapshot();
2480
+ // One row snapshot for this whole pass, handed to every
2481
+ // check below instead of being taken per session.
2482
+ const rows = readSessionRows();
2225
2483
  if (previous !== undefined) {
2226
2484
  for (const [sessionId, status] of snapshot) {
2227
2485
  // Sub-agent sessions stay out of it unless asked for:
2228
2486
  // the user wants the main conversation's alerts.
2229
- if (!isNotifiable(sessionId)) continue;
2487
+ if (!isNotifiable(sessionId, rows)) continue;
2230
2488
  const before = previous.get(sessionId);
2231
2489
  const beforePending = before === undefined ? undefined : before.pendingInteraction;
2232
2490
  if (status.pendingInteraction !== undefined && status.pendingInteraction !== beforePending) {
@@ -2252,7 +2510,7 @@ window.__ModuleLoader__.load({
2252
2510
  }
2253
2511
  }
2254
2512
  previous = snapshot;
2255
- trackPending(snapshot);
2513
+ trackPending(snapshot, rows);
2256
2514
  };
2257
2515
  // Returning to the page clears the alert even while the
2258
2516
  // decision stays pending — and marks every pending decision
@@ -2372,9 +2630,17 @@ window.__ModuleLoader__.load({
2372
2630
  let timer;
2373
2631
  const tick = () => {
2374
2632
  try {
2375
- if (form.getSnapshot().value?.updateAutoCheck === false) return;
2376
- fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2633
+ if (form.getSnapshot().value?.updateAutoCheck !== false) {
2634
+ fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2635
+ }
2377
2636
  } catch {}
2637
+ // Rescheduled unconditionally, and that is the fix rather
2638
+ // than a detail: returning early here — which is what the
2639
+ // chain used to do when the preference was off — ended the
2640
+ // self-scheduling for good. Turning the switch off once, or
2641
+ // simply loading the page with it off, meant no check ever
2642
+ // ran again until a full reload, while the row went on
2643
+ // promising "at most one check a day".
2378
2644
  timer = setTimeout(tick, 24 * 60 * 60 * 1000);
2379
2645
  timer.unref?.();
2380
2646
  };
@@ -2425,6 +2691,21 @@ window.__ModuleLoader__.load({
2425
2691
  exports.FOCUS_PATH = FOCUS_PATH;
2426
2692
  exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
2427
2693
  exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
2694
+ // Which update stages a retry can fix and which need a fresh install:
2695
+ // the Host names a stage in every failure it reports, so this map is the
2696
+ // one place the advice comes from, and the tests cross-check it against
2697
+ // the stage words the dictionaries carry.
2698
+ exports.UPDATE_STAGES = UPDATE_STAGES;
2699
+ // The two composed messages, so the tests can assert the sentences a
2700
+ // user actually reads rather than only the dictionary keys behind them.
2701
+ exports.updateAppliedText = updateAppliedText;
2702
+ exports.updateFailureText = updateFailureText;
2703
+ // The alert election, so the tests can drive two pages against a stubbed
2704
+ // storage and channel instead of hoping two real tabs cooperate.
2705
+ exports.createAlertLeader = createAlertLeader;
2706
+ // The peak scan, so a test can build the one buffer that tells a full
2707
+ // scan apart from a strided one.
2708
+ exports.normalizedGain = normalizedGain;
2428
2709
  exports.timeOfDayToMinutes = timeOfDayToMinutes;
2429
2710
  return module.exports;
2430
2711
  },
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@ import z from "@deepseek-ai/schemastery";
28
28
  import { spawn } from "node:child_process";
29
29
  import { createHash, timingSafeEqual } from "node:crypto";
30
30
  import { createRequire } from "node:module";
31
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
32
32
  import { dirname, join, resolve } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { gunzipSync } from "node:zlib";
@@ -188,13 +188,22 @@ let nativeRaise;
188
188
  * @param handle - the window to read.
189
189
  * @returns the title text.
190
190
  */
191
- function windowTitle(native, handle) {
191
+ export function windowTitle(native, handle) {
192
192
  try {
193
193
  const length = native.GetWindowTextLengthW(handle);
194
194
  if (length <= 0) return "";
195
195
  const buffer = new Uint16Array(length + 2);
196
- native.GetWindowTextW(handle, buffer, buffer.length);
197
- return Buffer.from(buffer.buffer, 0, length * 2).toString("utf16le");
196
+ const copied = native.GetWindowTextW(handle, buffer, buffer.length);
197
+ // The two calls are not atomic: between them the page can rewrite its
198
+ // title (switching sessions is exactly when it does), and this call
199
+ // then copies fewer characters than the first one counted. Slicing by
200
+ // the OLD count would hand back a string with NULs on the end — which
201
+ // no real title ever contains, and which silently stops `endsWith`
202
+ // matching, i.e. one click that does nothing at all. Slice by what was
203
+ // actually copied, and treat a nonsense return as "read the old count"
204
+ // rather than trusting it.
205
+ const written = Number.isFinite(copied) && copied >= 0 ? Math.min(copied, length) : length;
206
+ return Buffer.from(buffer.buffer, 0, written * 2).toString("utf16le");
198
207
  } catch {
199
208
  return "";
200
209
  }
@@ -545,11 +554,47 @@ export function compareVersions(a, b) {
545
554
  for (let i = 0; i < 3; i++) {
546
555
  if (left[i] !== right[i]) return left[i] - right[i];
547
556
  }
548
- // Same core: a prerelease sorts before its own release (1.5.0-rc.1 < 1.5.0).
549
- if (left[3] !== right[3]) {
550
- if (left[3] === "") return 1;
551
- if (right[3] === "") return -1;
552
- return left[3] < right[3] ? -1 : 1;
557
+ return comparePrerelease(left[3], right[3]);
558
+ }
559
+
560
+ /**
561
+ * Compare two prerelease fields the way semver defines it, not as strings.
562
+ *
563
+ * Plain string order gets the common case wrong: `rc.10` sorts BEFORE `rc.9`
564
+ * ("1" < "9"), so a registry whose `latest` points at a later release candidate
565
+ * would look older than the one already installed and the update would never be
566
+ * offered. The rules are: identifiers are compared field by field; a numeric
567
+ * field compares numerically and ranks below an alphanumeric one; an
568
+ * alphanumeric field compares in ASCII order; and when every shared field is
569
+ * equal, the one with MORE fields wins (`1.0.0-rc.1` < `1.0.0-rc.1.1`).
570
+ *
571
+ * @param left - the prerelease field of the first version, "" for a release.
572
+ * @param right - the prerelease field of the second version.
573
+ * @returns negative, 0, or positive.
574
+ */
575
+ function comparePrerelease(left, right) {
576
+ if (left === right) return 0;
577
+ // A release outranks any of its own prereleases (1.5.0 > 1.5.0-rc.1).
578
+ if (left === "") return 1;
579
+ if (right === "") return -1;
580
+ const a = left.split(".");
581
+ const b = right.split(".");
582
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
583
+ const x = a[i];
584
+ const y = b[i];
585
+ if (x === undefined) return -1;
586
+ if (y === undefined) return 1;
587
+ const numericX = /^\d+$/.test(x);
588
+ const numericY = /^\d+$/.test(y);
589
+ if (numericX && numericY) {
590
+ const dx = Number(x);
591
+ const dy = Number(y);
592
+ if (dx !== dy) return dx < dy ? -1 : 1;
593
+ continue;
594
+ }
595
+ // A numeric identifier always ranks below an alphanumeric one.
596
+ if (numericX !== numericY) return numericX ? -1 : 1;
597
+ if (x !== y) return x < y ? -1 : 1;
553
598
  }
554
599
  return 0;
555
600
  }
@@ -734,14 +779,41 @@ export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
734
779
  const UPDATE_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
735
780
 
736
781
  /**
737
- * The only files an update may replace, as package-relative paths.
782
+ * Largest expansion accepted after gunzip.
783
+ *
784
+ * The download cap alone does not bound memory: a few-hundred-KB gzip stream
785
+ * can expand to gigabytes, and that allocation happens inside this process.
786
+ * `gunzipSync` is given this as `maxOutputLength` so the limit is enforced
787
+ * while inflating rather than after.
788
+ */
789
+ const UPDATE_MAX_UNPACKED_BYTES = UPDATE_MAX_TARBALL_BYTES * 8;
790
+
791
+ /** Most files one release of this package may carry. It is a handful of text files. */
792
+ const UPDATE_MAX_FILES = 256;
793
+
794
+ /**
795
+ * Path segments an update may never contain, however an archive spells them.
796
+ *
797
+ * `.update-backup` is this updater's own working directory: an archive that
798
+ * carried a file with that name could leave the previous version's bytes
799
+ * exactly where a rollback looks for them. `node_modules` and `.git` are never
800
+ * published in a tarball, so an archive containing them describes a different
801
+ * tree than the one this installation came from.
802
+ */
803
+ const UPDATE_RESERVED_SEGMENTS = new Set(["node_modules", ".git", ".update-backup"]);
804
+
805
+ /**
806
+ * Records which files this updater installed, so the next update can prune.
738
807
  *
739
- * An update is a file swap, not an install: it replaces exactly what the
740
- * package publishes and touches nothing else. The profile's own configuration
741
- * lives outside this directory and is not on this list — no update can reach
742
- * it, whatever an archive contains.
808
+ * It lives in the installed package directory and is written by the updater at
809
+ * runtime — it is NEVER part of the published tarball, which is why adding it
810
+ * cannot break an older installation's update (those check the archive's file
811
+ * names, not what the directory happens to contain).
743
812
  */
744
- const UPDATE_FILES = ["package.json", "cordis.patch.yml", "README.md", "README.zh.md", "LICENSE", "lib/client.js", "lib/index.js"];
813
+ const UPDATE_MANIFEST = ".update-manifest.json";
814
+
815
+ /** Package-root names a package manager reads as configuration, and the updater's own bookkeeping; never delivered by an update. */
816
+ const UPDATE_RESERVED_ROOT_FILES = new Set([".npmrc", ".pnpmfile.cjs", UPDATE_MANIFEST]);
745
817
 
746
818
  /** Where this package is installed: the only tree an update may write to. */
747
819
  function packageRoot() {
@@ -765,24 +837,43 @@ function readTarOctal(header, offset, length) {
765
837
  }
766
838
 
767
839
  /**
768
- * Map an archive path to the package-relative file it may replace.
840
+ * Map an archive path to the package-relative file it may write.
769
841
  *
770
842
  * Everything about a tar path is hostile until shown otherwise: only the
771
- * `package/` prefix the registry publishes under, only the whitelisted names,
772
- * and never a path that climbs, escapes, or arrives by another separator.
843
+ * `package/` prefix the registry publishes under, never a path that climbs,
844
+ * escapes, or arrives by another separator, never a reserved name, and never
845
+ * one that an NTFS alternate data stream could hide behind.
846
+ *
847
+ * What is deliberately NOT checked is the file's NAME. An exact list of seven
848
+ * names was the previous design and it was a trap: the updater that runs during
849
+ * an update is the one already installed, so its list decides what the NEXT
850
+ * release may contain. A release that added a file became uninstallable by
851
+ * every older installation — permanently, and with no way to fix it after the
852
+ * fact, because the old code is already on those machines. The rules below are
853
+ * the ones that never need to change (path safety, not a file inventory), so
854
+ * they can remain the whole gate while the file set moves forward.
773
855
  */
774
856
  export function archivePathToRelative(path) {
775
857
  const normalized = path.replaceAll("\\", "/");
776
- if (normalized.startsWith("/") || normalized.split("/").includes("..")) throw new Error(`refusing tar path: ${path}`);
858
+ const segments = normalized.split("/");
859
+ if (normalized.startsWith("/") || segments.includes("..")) throw new Error(`refusing tar path: ${path}`);
777
860
  const prefix = "package/";
778
861
  if (!normalized.startsWith(prefix)) throw new Error(`refusing tar path: ${path}`);
779
862
  const relative = normalized.slice(prefix.length);
780
- if (relative === "" || !UPDATE_FILES.includes(relative)) throw new Error(`refusing tar path: ${path}`);
863
+ if (relative === "" || relative.endsWith("/")) throw new Error(`refusing tar path: ${path}`);
864
+ for (const segment of relative.split("/")) {
865
+ // A `.` segment would let one file be named twice in one archive, and it
866
+ // slips past the case check below, which compares names rather than
867
+ // resolved paths.
868
+ if (segment === "" || segment === "." || segment.includes(":")) throw new Error(`refusing tar path: ${path}`);
869
+ if (UPDATE_RESERVED_SEGMENTS.has(segment) || segment.endsWith(".update-tmp")) throw new Error(`refusing tar path: ${path}`);
870
+ }
871
+ if (UPDATE_RESERVED_ROOT_FILES.has(relative)) throw new Error(`refusing tar path: ${path}`);
781
872
  return relative;
782
873
  }
783
874
 
784
875
  /**
785
- * Read a tar archive into the few files an update may touch.
876
+ * Read a tar archive into the files an update may write.
786
877
  *
787
878
  * The archive arrived from the network and is treated accordingly:
788
879
  *
@@ -790,13 +881,19 @@ export function archivePathToRelative(path) {
790
881
  * anywhere, so any other entry type is refused outright;
791
882
  * - pax/gnu extension headers are skipped, not interpreted;
792
883
  * - every name must survive `archivePathToRelative`, so nothing outside
793
- * this package is ever named;
794
- * - a header claiming more than the archive holds is an error, not a read.
884
+ * this package is ever named and nothing reserved is ever written;
885
+ * - two entries may not differ only in case: on Windows and macOS the second
886
+ * would silently overwrite the first;
887
+ * - a header claiming more than the archive holds is an error, not a read;
888
+ * - the total size and the file count are capped, so an archive cannot turn
889
+ * a bounded download into unbounded disk usage.
795
890
  *
796
891
  * @returns a Map of package-relative path → contents.
797
892
  */
798
893
  export function unpackTar(bytes) {
799
894
  const files = new Map();
895
+ const seen = new Map();
896
+ let total = 0;
800
897
  let offset = 0;
801
898
  while (offset + 512 <= bytes.length) {
802
899
  const header = bytes.subarray(offset, offset + 512);
@@ -812,12 +909,79 @@ export function unpackTar(bytes) {
812
909
  offset = contentStart + Math.ceil(size / 512) * 512;
813
910
  if (typeflag === "x" || typeflag === "g" || typeflag === "L") continue;
814
911
  if (typeflag !== "0" && typeflag !== "") throw new Error(`refusing non-file tar entry (${typeflag})`);
815
- files.set(archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`), content);
912
+ const relative = archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`);
913
+ const folded = relative.toLowerCase();
914
+ const collidesWith = seen.get(folded);
915
+ if (collidesWith !== undefined && collidesWith !== relative) {
916
+ throw new Error(`refusing two entries that differ only in case: ${collidesWith} / ${relative}`);
917
+ }
918
+ seen.set(folded, relative);
919
+ total += content.length;
920
+ if (total > UPDATE_MAX_UNPACKED_BYTES) throw new Error("the archive expands beyond its limit");
921
+ if (files.size >= UPDATE_MAX_FILES) throw new Error("the archive carries more files than this package can have");
922
+ files.set(relative, content);
816
923
  }
817
924
  if (files.size === 0) throw new Error("the archive carried no usable files");
818
925
  return files;
819
926
  }
820
927
 
928
+ /**
929
+ * Check that the archive really is this package, at the version the registry named.
930
+ *
931
+ * With the file inventory gone, this is what still ties the bytes to an
932
+ * identity. The registry answer was validated before anything was downloaded,
933
+ * and the digest binds the bytes to that answer's `dist` — but the archive's
934
+ * own manifest is the only thing that says WHICH package and version those
935
+ * bytes are, and the next check trusts the manifest this update leaves on
936
+ * disk. A manifest that is missing, unreadable, or about something else would
937
+ * otherwise be installed silently and make "an update landed" a lie the check
938
+ * cannot see through.
939
+ *
940
+ * @param files - the unpacked archive, package-relative path → contents.
941
+ * @param version - the version the registry announced.
942
+ * @returns the parsed manifest.
943
+ */
944
+ export function verifyManifest(files, version) {
945
+ const manifest = files.get("package.json");
946
+ if (manifest === undefined) throw new Error("the archive carries no package.json");
947
+ let parsed;
948
+ try {
949
+ parsed = JSON.parse(new TextDecoder().decode(manifest));
950
+ } catch {
951
+ throw new Error("the archive's package.json is not readable JSON");
952
+ }
953
+ if (parsed?.name !== UPDATE_PACKAGE) throw new Error(`the archive is not ${UPDATE_PACKAGE}`);
954
+ if (parsed?.version !== version) {
955
+ throw new Error(`the archive says version ${String(parsed?.version)} where the registry said ${version}`);
956
+ }
957
+ // A file whose name differs from a path this manifest declares only by case
958
+ // would replace that file on Windows and macOS and be a dead file on Linux:
959
+ // one archive, two different installations. The set is derived from the
960
+ // archive's OWN manifest (`package.json` itself, `main`, and every `exports`
961
+ // target) — never from a list held here — so a release stays free to move
962
+ // or rename its own files, which is the whole point of this design.
963
+ const declared = new Map([["package.json", "package.json"]]);
964
+ const declare = (value) => {
965
+ if (typeof value !== "string" || value === "") return;
966
+ const relative = value.startsWith("./") ? value.slice(2) : value;
967
+ declared.set(relative.toLowerCase(), relative);
968
+ };
969
+ const declareAll = (value) => {
970
+ if (typeof value === "string") declare(value);
971
+ else if (Array.isArray(value)) value.forEach(declareAll);
972
+ else if (value !== null && typeof value === "object") Object.values(value).forEach(declareAll);
973
+ };
974
+ declare(parsed.main);
975
+ declareAll(parsed.exports);
976
+ for (const relative of files.keys()) {
977
+ const target = declared.get(relative.toLowerCase());
978
+ if (target !== undefined && target !== relative) {
979
+ throw new Error(`refusing a file that differs only in case from ${target}: ${relative}`);
980
+ }
981
+ }
982
+ return parsed;
983
+ }
984
+
821
985
  /**
822
986
  * Check downloaded bytes against the digest the registry published.
823
987
  *
@@ -873,6 +1037,48 @@ async function downloadTarball(url) {
873
1037
  throw new Error(`download refused: more than ${UPDATE_MAX_REDIRECTS} redirects`);
874
1038
  }
875
1039
 
1040
+ /**
1041
+ * The file list a previous update recorded, as a Set.
1042
+ *
1043
+ * A missing, unreadable, or malformed manifest reads as "nothing recorded",
1044
+ * which prunes nothing: the failure mode of a damaged manifest has to be
1045
+ * "leaves a file behind", never "deletes something".
1046
+ *
1047
+ * @param root - the package directory.
1048
+ * @returns the recorded package-relative paths.
1049
+ */
1050
+ function readUpdateManifest(root) {
1051
+ try {
1052
+ const parsed = JSON.parse(readFileSync(join(root, UPDATE_MANIFEST), "utf8"));
1053
+ if (!Array.isArray(parsed?.files)) return new Set();
1054
+ return new Set(parsed.files.filter((name) => typeof name === "string"));
1055
+ } catch {
1056
+ return new Set();
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Which recorded files this release no longer carries.
1062
+ *
1063
+ * Only paths the PREVIOUS update wrote are candidates, so a file the user put
1064
+ * in the package directory themselves is never named here and never removed.
1065
+ * This is what makes an update add and remove files while the file-list rule
1066
+ * stays out of the way — the alternative, shipping deletions inside the
1067
+ * archive, is not something a tar can express.
1068
+ *
1069
+ * @param previous - paths recorded by the last update.
1070
+ * @param files - the incoming archive's package-relative paths.
1071
+ * @returns the paths to delete, sorted.
1072
+ */
1073
+ export function filesToPrune(previous, files) {
1074
+ const gone = [];
1075
+ for (const relative of previous) {
1076
+ if (relative === UPDATE_MANIFEST) continue;
1077
+ if (!files.has(relative)) gone.push(relative);
1078
+ }
1079
+ return gone.sort();
1080
+ }
1081
+
876
1082
  /**
877
1083
  * Swap the package's files for the update's, keeping the previous ones.
878
1084
  *
@@ -880,41 +1086,127 @@ async function downloadTarball(url) {
880
1086
  * failure cannot leave a half-written file under a real name. Everything the
881
1087
  * update is about to replace is copied aside first, and any failure puts it
882
1088
  * all back: a failed update is not a half-updated plugin.
1089
+ *
1090
+ * The files are whatever the archive carried, not a list held here. An earlier
1091
+ * design iterated a fixed set of seven names, which meant a release could only
1092
+ * ever touch those seven; that list is gone on purpose (see
1093
+ * `archivePathToRelative`), and this function is the second half of the same
1094
+ * decision — unpacking a new file and then never writing it would look exactly
1095
+ * like a successful update that quietly did nothing.
883
1096
  */
884
1097
  export function installUpdate(root, files) {
885
1098
  const rootPrefix = resolve(root);
886
1099
  const backup = join(root, ".update-backup");
887
1100
  const tempName = (relative) => `${resolve(root, relative)}.update-tmp`;
888
1101
  rmSync(backup, { recursive: true, force: true });
1102
+ // The manifest goes last, and that order is load-bearing. If this process
1103
+ // dies mid-swap — a power cut or a kill, not a thrown error, which rolls
1104
+ // back — the installation is left with the OLD version number over part of
1105
+ // the new code, so the next check still reports an update and the user can
1106
+ // retry. The opposite order would leave a NEW version number over the old
1107
+ // code: the check would call that "up to date" and there would be nothing
1108
+ // left to prompt anyone with.
1109
+ const relatives = [...files.keys()].sort((a, b) => (a === "package.json" ? 1 : 0) - (b === "package.json" ? 1 : 0));
1110
+ /** Directories this update creates, outermost first, so a rollback can take them back. */
1111
+ const created = [];
889
1112
  const written = [];
1113
+ /** Files this update deletes, kept aside first so a rollback can bring them back. */
1114
+ const pruned = [];
1115
+ /** The previous update's record, read before anything is written over it. */
1116
+ const previous = readUpdateManifest(root);
1117
+ const manifestPath = join(root, UPDATE_MANIFEST);
1118
+ /** Copy the current bytes aside before they are replaced or removed. */
1119
+ const backUp = (relative, target) => {
1120
+ const aside = join(backup, relative);
1121
+ mkdirSync(dirname(aside), { recursive: true });
1122
+ writeFileSync(aside, readFileSync(target));
1123
+ };
890
1124
  try {
891
- for (const relative of UPDATE_FILES) {
1125
+ for (const relative of relatives) {
892
1126
  const source = files.get(relative);
893
1127
  if (source === undefined) continue;
894
1128
  const target = resolve(root, relative);
895
1129
  if (target !== rootPrefix && !target.startsWith(rootPrefix)) throw new Error(`refusing to write outside the package: ${relative}`);
896
- if (existsSync(target)) {
897
- const aside = join(backup, relative);
898
- mkdirSync(dirname(aside), { recursive: true });
899
- writeFileSync(aside, readFileSync(target));
1130
+ let parent = rootPrefix;
1131
+ for (const segment of relative.split("/").slice(0, -1)) {
1132
+ parent = join(parent, segment);
1133
+ if (!existsSync(parent)) created.push(parent);
900
1134
  }
1135
+ mkdirSync(dirname(target), { recursive: true });
1136
+ if (existsSync(target)) backUp(relative, target);
901
1137
  const temp = tempName(relative);
902
1138
  writeFileSync(temp, source);
903
1139
  renameSync(temp, target);
904
1140
  written.push(relative);
905
1141
  }
1142
+ // A release can also DROP a file, and an archive cannot say so: it is a
1143
+ // list of what exists, not of what should be deleted. The previous
1144
+ // update's record is what makes the difference computable, and it is
1145
+ // deliberately limited to files an update wrote — anything else in this
1146
+ // directory (a user's own notes, a tool's leftovers) is never named in
1147
+ // the record and therefore never deleted.
1148
+ for (const relative of filesToPrune(previous, files)) {
1149
+ const target = resolve(root, relative);
1150
+ if (target !== rootPrefix && !target.startsWith(rootPrefix)) continue;
1151
+ // Files only. A path that is now a directory is left alone rather
1152
+ // than failing the whole update, and never removed recursively.
1153
+ try {
1154
+ if (!statSync(target).isFile()) continue;
1155
+ } catch {
1156
+ continue;
1157
+ }
1158
+ backUp(relative, target);
1159
+ rmSync(target, { force: true });
1160
+ pruned.push(relative);
1161
+ }
1162
+ // The record is written LAST, through the same temporary-name swap as
1163
+ // everything else, and it describes the state this update leaves behind:
1164
+ // the incoming files plus itself, minus nothing.
1165
+ if (existsSync(manifestPath)) backUp(UPDATE_MANIFEST, manifestPath);
1166
+ const record = new TextEncoder().encode(JSON.stringify({ version: 1, files: [...files.keys(), UPDATE_MANIFEST].sort() }));
1167
+ writeFileSync(`${manifestPath}.update-tmp`, record);
1168
+ renameSync(`${manifestPath}.update-tmp`, manifestPath);
1169
+ written.push(UPDATE_MANIFEST);
906
1170
  } catch (error) {
907
1171
  // Put everything back — including files the update ADDED, which have
908
- // no backup to restore from and must simply go away again. A failed
909
- // update has to leave nothing behind: no half-replaced file, no
910
- // leftover temp, and no file that was not there before.
1172
+ // no backup to restore from and must simply go away again, the files it
1173
+ // DELETED, which come back from their copies, and the directories it
1174
+ // created. A failed update has to leave nothing behind: no half-replaced
1175
+ // file, no leftover temp, no file that was not there before, and no
1176
+ // empty directory either.
911
1177
  for (const relative of written) {
912
1178
  const aside = join(backup, relative);
913
1179
  const target = resolve(root, relative);
914
1180
  if (existsSync(aside)) writeFileSync(target, readFileSync(aside));
915
1181
  else rmSync(target, { force: true });
916
1182
  }
917
- for (const relative of UPDATE_FILES) rmSync(tempName(relative), { force: true });
1183
+ for (const relative of pruned) {
1184
+ const aside = join(backup, relative);
1185
+ if (existsSync(aside)) writeFileSync(resolve(root, relative), readFileSync(aside));
1186
+ }
1187
+ for (const relative of relatives) rmSync(tempName(relative), { force: true });
1188
+ // Guarded: a cleanup step must never replace the failure it is cleaning
1189
+ // up after (this path can hold a directory, and `rmSync` refuses one
1190
+ // without `recursive`).
1191
+ try {
1192
+ rmSync(`${manifestPath}.update-tmp`, { force: true });
1193
+ } catch {}
1194
+ // Deepest first, so a chain collapses one level at a time. `rmdirSync`
1195
+ // refuses a directory that still holds something, which is the point:
1196
+ // a failure here must never delete real content. (Not `rmSync` — that
1197
+ // one needs `recursive` even for an empty directory on Windows and
1198
+ // throws EISDIR, which would have left every created directory behind.)
1199
+ for (const directory of created.reverse()) {
1200
+ try {
1201
+ rmdirSync(directory);
1202
+ } catch {}
1203
+ }
1204
+ // The restore above is done, so the copies have no further use. If a
1205
+ // restore threw, this is never reached and the backup stays on disk —
1206
+ // which is the one case where keeping it is the safer outcome.
1207
+ try {
1208
+ rmSync(backup, { recursive: true, force: true });
1209
+ } catch {}
918
1210
  throw error;
919
1211
  }
920
1212
  // The swap succeeded, so the backup has done its job. Leaving it would
@@ -930,8 +1222,10 @@ export function installUpdate(root, files) {
930
1222
  * registry (the answer is validated as strictly as for a check); download the
931
1223
  * tarball from the URL that answer named and nowhere else; verify its sha512
932
1224
  * against the digest the registry published — a mismatch stops here, before a
933
- * byte is written; unpack it in memory, accepting only whitelisted regular
934
- * files; then back up what is there and swap the files, restoring on failure.
1225
+ * byte is written; unpack it in memory, accepting only regular files that stay
1226
+ * inside the package, and confirm its manifest is this package at the version
1227
+ * the registry announced; then back up what is there and swap the files,
1228
+ * restoring on failure.
935
1229
  *
936
1230
  * Nothing downloaded is ever executed here. The new code runs when DSH
937
1231
  * restarts, which is the same trust model as the user updating by hand.
@@ -959,17 +1253,25 @@ async function runUpdate(options = {}) {
959
1253
  const root = options.root ?? packageRoot();
960
1254
  const current = readOwnVersion(root);
961
1255
  let stage = "check";
1256
+ /** The version the registry announced, once there is one, for the failure report. */
1257
+ let announced;
962
1258
  try {
963
1259
  const answer = await fetchRegistryAnswer();
964
1260
  if (answer === undefined) return { ok: false, stage, error: "no registry answered" };
965
1261
  const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
966
1262
  if (Number.isFinite(ordering) && ordering >= 0) return { ok: true, upToDate: true, current };
1263
+ announced = answer.version;
967
1264
  stage = "download";
968
1265
  const bytes = await downloadTarball(answer.tarball);
969
1266
  stage = "verify";
970
1267
  verifyIntegrity(bytes, answer.integrity);
971
1268
  stage = "unpack";
972
- const files = unpackTar(gunzipSync(bytes));
1269
+ // A gzip stream can expand far beyond the bytes it arrived in, so the
1270
+ // decompressor is told where to stop instead of being trusted to be
1271
+ // reasonable; the tarball cap above bounds the input, not the output.
1272
+ const files = unpackTar(gunzipSync(bytes, { maxOutputLength: UPDATE_MAX_UNPACKED_BYTES }));
1273
+ stage = "manifest";
1274
+ verifyManifest(files, answer.version);
973
1275
  stage = "replace";
974
1276
  installUpdate(root, files);
975
1277
  // The answer that said "update available" is now a lie about this
@@ -986,7 +1288,7 @@ async function runUpdate(options = {}) {
986
1288
  return { ok: true, from: current, to: answer.version, requiresRestart: true };
987
1289
  } catch (error) {
988
1290
  warn(`update failed at ${stage}: ${error?.message ?? error}`);
989
- return { ok: false, stage, error: error?.message ?? String(error) };
1291
+ return { ok: false, stage, to: announced, error: error?.message ?? String(error) };
990
1292
  }
991
1293
  }
992
1294
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-notify-sounds",
3
- "version": "1.6.9",
3
+ "version": "1.7.0",
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,8 +18,7 @@
18
18
  "lib/index.js",
19
19
  "lib/client.js",
20
20
  "cordis.patch.yml",
21
- "README.zh.md",
22
- "assets"
21
+ "README.zh.md"
23
22
  ],
24
23
  "license": "MIT",
25
24
  "repository": {