dsh-plugin-notify-sounds 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,8 @@ For anyone hacking on the code (exact details live in the source comments):
123
123
 
124
124
  ## Changelog
125
125
 
126
+ - **v1.6.3** (2026-10-11) The update-success message no longer flashes and vanishes: the Host remembers the install and repeats it with every check, so "Updated, restart DSH to load it" survives the page reload the update itself triggers (it swaps the client half's own file) and stays until the restart. The cached check is invalidated after an install too, so the row can no longer claim a newer version than the one it just wrote.
127
+ - **v1.6.2** (2026-10-11) Fixed one-click update always failing when the mainland mirror answered the check: the mirror serves its tarballs from its CDN, and the downloader refused every redirect — so the download could never finish (found by the live drill). Redirects are now followed one hop at a time with every hop checked against the trusted hosts (the mirror's CDN is the one legitimate hop); the sha512 check is unchanged. A failure now names the stage it died at (check / download / verify / unpack / replace).
126
128
  - **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
129
  - **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.
128
130
  - **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.
package/README.zh.md CHANGED
@@ -123,6 +123,8 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
123
123
 
124
124
  ## 更新记录
125
125
 
126
+ - **v1.6.3**(2026-10-11)更新成功的提示不再一闪而过:更新结果改由**宿主**记住、随每次检查下发——即便更新替换掉了客户端自身文件、页面热重载丢掉本地状态,「更新完成:vX → vY,重启 DSH 后生效」也会一直显示到你重启;同时更新后**作废检查缓存**,不会再出现「明明刚升完却仍显示有新版」。
127
+ - **v1.6.2**(2026-10-11)修复一键更新在**镜像源**下必然失败:npmmirror 的 tarball 会跳转到它的 CDN,而下载器此前拒绝一切重定向,于是「只要检查由镜像应答,下载就永远到不了」(真机演练实测发现)。现在重定向改为逐跳跟随、每一跳都必须仍落在受信主机上(镜像 CDN 是唯一合法跳转),sha512 校验不变;失败提示会带上失败阶段(下载 / 校验 / 解包 / 替换),便于定位。
126
128
  - **v1.6.1**(2026-10-11)README 重排为发布页风格:补「**与 DSH 界面保持一致**」一节讲清界面取自 DSH 自身设计体系;通篇表达润色,并修正升级说明(本插件自带更新,无需卸载重装)。本版同时是「一键更新」的发布演练。
127
129
  - **v1.6.0**(2026-10-11)新增**检查更新与一键更新**:设置里可查 npm 新版本并一键安装——下载后按 registry 公布的 sha512 校验、只解包白名单文件(拒绝符号链接与路径穿越)、先备份再替换、失败自动还原;下载内容从不执行,新代码在 DSH 重启时生效。自动检查每天最多一次、可关;npm 官方源不通时自动改用国内镜像。
128
130
  - **v1.5.0**(2026-10-08)「已完成」标题标记改为未读语义——离开期间发生的完成才标记,回到页面即清除,不再每次切走都重新出现;触发冷却按会话独立,2 秒内两个会话先后需要决策时都会响。补齐宿主半测试(配置 schema、唤窗路由、标题匹配)并交叉校验两半共享常量;`dsh.client.inject` 显式声明 sessions 提供包;静音时段结束后的补发行为写入文档。
package/lib/client.js CHANGED
@@ -193,7 +193,7 @@ window.__ModuleLoader__.load({
193
193
  "update.updating": "更新中…",
194
194
  "update.done": "更新完成",
195
195
  "update.restart": "重启 DSH 后生效",
196
- "update.applyFailed": "更新失败,已恢复原状",
196
+ "update.applyFailed": "更新失败({stage}),已恢复原状",
197
197
  };
198
198
 
199
199
  /** English dictionary, checked complete against the zh key set. */
@@ -270,7 +270,7 @@ window.__ModuleLoader__.load({
270
270
  "update.updating": "Updating…",
271
271
  "update.done": "Updated",
272
272
  "update.restart": "restart DSH to load it",
273
- "update.applyFailed": "Update failed — the previous version is intact",
273
+ "update.applyFailed": "Update failed ({stage}) — the previous version is intact",
274
274
  };
275
275
  //#endregion
276
276
 
@@ -1583,6 +1583,14 @@ window.__ModuleLoader__.load({
1583
1583
  }, []);
1584
1584
  const payload = state.payload;
1585
1585
  const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1586
+ // The Host remembers the last install and repeats it with every
1587
+ // answer, and this row prefers that: an update swaps the client
1588
+ // half's own file, so the page may reload and drop this row's
1589
+ // state mid-message — the news has to survive that. The Host keeps
1590
+ // it until DSH restarts, which is exactly as long as "restart to
1591
+ // load it" is true.
1592
+ const applied = payload?.applied ?? (state.phase === "updated" ? state.result : undefined);
1593
+ const appliedDone = applied !== undefined && (applied.ok === true || typeof applied.to === "string");
1586
1594
  let status = payload === undefined
1587
1595
  ? t("update.idle")
1588
1596
  : payload.updateAvailable === true
@@ -1590,10 +1598,11 @@ window.__ModuleLoader__.load({
1590
1598
  : t("update.uptodate");
1591
1599
  if (state.phase === "checking") status = t("update.checking");
1592
1600
  if (state.phase === "updating") status = t("update.updating");
1593
- if (state.phase === "applyFailed") status = t("update.applyFailed");
1594
1601
  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")}`;
1602
+ if (appliedDone) {
1603
+ detail = `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`;
1604
+ } else if (state.phase === "applyFailed") {
1605
+ detail = t("update.applyFailed", { stage: String(state.result?.stage ?? "?") });
1597
1606
  }
1598
1607
  const busy = state.phase === "checking" || state.phase === "updating";
1599
1608
  return h("div", { className: css.row }, [
@@ -1603,7 +1612,7 @@ window.__ModuleLoader__.load({
1603
1612
  h("div", { className: css.hint, key: "status" }, detail),
1604
1613
  ]),
1605
1614
  h("div", { className: css.stackControl, key: "control" }, [
1606
- payload?.updateAvailable === true && state.phase !== "updated" ? h(Button, {
1615
+ payload?.updateAvailable === true && !appliedDone ? h(Button, {
1607
1616
  key: "apply",
1608
1617
  variant: "outline",
1609
1618
  size: "sm",
package/lib/index.js CHANGED
@@ -443,6 +443,21 @@ const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
443
443
  */
444
444
  const UPDATE_REGISTRIES = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
445
445
 
446
+ /**
447
+ * Hosts a tarball may live on: the registries above, plus the one legitimate
448
+ * hop between them.
449
+ *
450
+ * The mainland mirror answers its metadata on its own host but serves the
451
+ * tarball from its CDN, so a download that refuses every redirect could never
452
+ * finish when the mirror answered (measured). The CDN is therefore trusted
453
+ * for tarballs only, and redirects are still followed one hop at a time with
454
+ * every hop checked against this list.
455
+ */
456
+ const UPDATE_TARBALL_HOSTS = [...UPDATE_REGISTRIES, "https://cdn.npmmirror.com"];
457
+
458
+ /** How many redirects a download may follow before giving up. */
459
+ const UPDATE_MAX_REDIRECTS = 4;
460
+
446
461
  /** How long one check result is trusted before the next check may run. */
447
462
  const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
448
463
 
@@ -453,8 +468,8 @@ const UPDATE_CHECK_SPACING_MS = 15 * 1000;
453
468
  const UPDATE_FETCH_TIMEOUT_MS = 10 * 1000;
454
469
  const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
455
470
 
456
- /** Result of the last check, and when the last real request went out. */
457
- const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined };
471
+ /** Result of the last check, when the last real request went out, and the last successful install. */
472
+ const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined, applied: undefined };
458
473
 
459
474
  /**
460
475
  * Parse `major.minor.patch` with an optional prerelease tag.
@@ -511,15 +526,16 @@ function readOwnVersion(root) {
511
526
  * Whether a tarball URL is somewhere this plugin is willing to download from.
512
527
  *
513
528
  * 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.
529
+ * only, and the host must be one of `UPDATE_TARBALL_HOSTS`. A registry pointing
530
+ * anywhere else is refused rather than followed. The download stage re-checks
531
+ * every redirect against this same list, so a hostile answer — or a hostile
532
+ * redirect — is rejected at the door instead of at the moment it is about to
533
+ * be used.
518
534
  */
519
535
  export function isTrustedTarball(url) {
520
536
  try {
521
537
  const parsed = new URL(url);
522
- return parsed.protocol === "https:" && UPDATE_REGISTRIES.some((registry) => parsed.origin === registry);
538
+ return parsed.protocol === "https:" && UPDATE_TARBALL_HOSTS.some((host) => parsed.origin === host);
523
539
  } catch {
524
540
  return false;
525
541
  }
@@ -584,10 +600,10 @@ export async function checkForUpdate(options = {}) {
584
600
  return updateCheck.result;
585
601
  }
586
602
  updateCheck.requestedAt = now;
587
- const current = readOwnVersion();
603
+ const current = readOwnVersion(options.root);
588
604
  const answer = await fetchRegistryAnswer();
589
605
  if (answer === undefined) {
590
- const result = { ok: false, current, error: "no registry answered" };
606
+ const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied };
591
607
  updateCheck.checkedAt = now;
592
608
  updateCheck.result = result;
593
609
  return result;
@@ -599,6 +615,7 @@ export async function checkForUpdate(options = {}) {
599
615
  latest: answer.version,
600
616
  updateAvailable: Number.isFinite(ordering) && ordering < 0,
601
617
  checkedAt: now,
618
+ applied: updateCheck.applied,
602
619
  };
603
620
  updateCheck.checkedAt = now;
604
621
  updateCheck.result = result;
@@ -756,20 +773,42 @@ export function verifyIntegrity(bytes, integrity) {
756
773
  /**
757
774
  * Download the tarball a validated registry answer named.
758
775
  *
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.
776
+ * Redirects are followed by hand rather than by the fetch layer: every hop's
777
+ * URL must itself be on `UPDATE_TARBALL_HOSTS`, so a redirect can never walk
778
+ * off the list — the mirror's CDN hop is the one legitimate case, and anything
779
+ * else is refused by name. The response is capped and time-limited like every
780
+ * other request; the sha512 check afterwards is what actually vouches for the
781
+ * bytes, whatever host they came from.
782
+ *
783
+ * @returns the tarball bytes.
762
784
  */
763
785
  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);
786
+ let current = url;
787
+ for (let hop = 0; hop <= UPDATE_MAX_REDIRECTS; hop++) {
788
+ if (!isTrustedTarball(current)) throw new Error(`download refused: ${current}`);
789
+ log(`update: downloading ${current}`);
790
+ let response;
791
+ try {
792
+ response = await fetch(current, {
793
+ signal: AbortSignal.timeout(60 * 1000),
794
+ redirect: "manual",
795
+ headers: { accept: "application/octet-stream" },
796
+ });
797
+ } catch (error) {
798
+ throw new Error(`download failed: ${error?.message ?? error}`);
799
+ }
800
+ if (response.status >= 300 && response.status < 400) {
801
+ const location = response.headers.get("location");
802
+ if (typeof location !== "string" || location === "") {
803
+ throw new Error(`download failed: HTTP ${response.status} without a redirect target`);
804
+ }
805
+ current = new URL(location, current).toString();
806
+ continue;
807
+ }
808
+ if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
809
+ return readCappedBytes(response, UPDATE_MAX_TARBALL_BYTES);
810
+ }
811
+ throw new Error(`download refused: more than ${UPDATE_MAX_REDIRECTS} redirects`);
773
812
  }
774
813
 
775
814
  /**
@@ -844,6 +883,16 @@ export async function applyUpdate(options = {}) {
844
883
  const files = unpackTar(gunzipSync(bytes));
845
884
  stage = "replace";
846
885
  installUpdate(root, files);
886
+ // The answer that said "update available" is now a lie about this
887
+ // installation: drop the cached check so the next one re-reads the
888
+ // manifest this install just wrote. The outcome is remembered too —
889
+ // the very files that were just swapped include the client half, so
890
+ // the page may reload and lose its own state, and the news that an
891
+ // update landed must survive that. It is kept until DSH restarts,
892
+ // which is exactly as long as "restart to load it" is true.
893
+ updateCheck.checkedAt = 0;
894
+ updateCheck.result = undefined;
895
+ updateCheck.applied = { from: current, to: answer.version, at: Date.now() };
847
896
  log(`update applied: ${current ?? "?"} -> ${answer.version}`);
848
897
  return { ok: true, from: current, to: answer.version, requiresRestart: true };
849
898
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-notify-sounds",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
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",