@linxin666/dsh-remote-web-ui 0.1.14 → 0.1.16

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.
Files changed (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -5
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +84 -23
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +173 -18
  7. package/lib/mobile.js +44 -25
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +30 -8
  12. package/lib/types/client/UpdatePanel.js +2 -0
  13. package/lib/types/client/locales.d.ts +4 -0
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +4 -0
  16. package/lib/types/client/settings-form.d.ts +76 -5
  17. package/lib/types/client/settings-form.d.ts.map +1 -1
  18. package/lib/types/client/settings-form.js +97 -20
  19. package/lib/types/index.d.ts.map +1 -1
  20. package/lib/types/index.js +36 -10
  21. package/lib/types/mobile/mux.d.ts +13 -4
  22. package/lib/types/mobile/mux.d.ts.map +1 -1
  23. package/lib/types/mobile/mux.js +50 -30
  24. package/lib/types/mobile/mux.test.js +75 -6
  25. package/lib/types/mobile-api.d.ts +2 -0
  26. package/lib/types/mobile-api.d.ts.map +1 -1
  27. package/lib/types/mobile-api.js +4 -1
  28. package/lib/types/routes.d.ts +17 -0
  29. package/lib/types/routes.d.ts.map +1 -1
  30. package/lib/types/routes.js +49 -10
  31. package/lib/types/update.d.ts +44 -4
  32. package/lib/types/update.d.ts.map +1 -1
  33. package/lib/types/update.js +118 -8
  34. package/package.json +1 -1
  35. package/src/client/PluginSettingsCard.tsx +90 -18
  36. package/src/client/UpdatePanel.tsx +2 -0
  37. package/src/client/locales.ts +4 -0
  38. package/src/client/settings-card.module.css +9 -0
  39. package/src/client/settings-form.ts +153 -19
  40. package/src/index.ts +34 -10
  41. package/src/mobile/mux.test.ts +84 -6
  42. package/src/mobile/mux.ts +46 -25
  43. package/src/mobile-api.ts +6 -1
  44. package/src/routes.ts +51 -10
  45. package/src/update.ts +128 -9
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 97f5f6b000e12fd89ad8723fd25306075a10df06
6
- README.zh.md: 30c7d55a4f31e914091318c3205d23f3b9979f60
5
+ README.md: c1e5ea0deaca90a5a188342db0d38256f5917369
6
+ README.zh.md: 9be2fbeb481ea23f05aa06f732234b2310c3d16a
package/README.md CHANGED
@@ -38,12 +38,16 @@ actions, and the update panel that probes and runs the update.
38
38
  - **Remote update**: the download trigger in the sidebar foot (left of the
39
39
  phone icon) opens the update panel, which probes the npm registry for the
40
40
  installed `@linxin666/dsh-*` family releases. When a newer release exists
41
- the panel runs the update automatically (`pnpm update` inside the owning
42
- dsh profile; when pnpm is missing it falls back to `corepack pnpm` and
43
- then `npx --yes pnpm`, and on Windows the command runs through `cmd.exe`
44
- so npm-installed `.cmd` shims resolve; the loopback-only
41
+ the panel runs the update automatically (`pnpm update --latest` inside the
42
+ owning dsh profile; when pnpm is missing it falls back to `corepack pnpm`
43
+ and then `npx --yes pnpm`, and on Windows the command runs through
44
+ `cmd.exe` so npm-installed `.cmd` shims resolve; the loopback-only
45
45
  `/api/update/status` + `/api/update/run` endpoints drive it) and asks for
46
- a dsh web restart to pick it up. Local
46
+ a dsh web restart to pick it up. After a green pnpm exit the installed
47
+ versions are re-checked against the registry: a green exit that left every
48
+ version in place (e.g. the pnpm `minimumReleaseAge` gate silently skipping
49
+ same-day releases) is reported as a stale update with configuration
50
+ guidance instead of a false success. Local
47
51
  link installs (development mode) are detected and report the npm state
48
52
  without updating.
49
53
 
package/README.zh.md CHANGED
@@ -13,7 +13,7 @@
13
13
  - **手机侧**:扫码将手机与一次性、限时令牌绑定,并落地到 **`/m` 独立移动端界面**——一款专为小屏设计的轻客户端(见[截图](#截图)),而不是把桌面 UI 塞进手机。链接携带 `workspace` 参数,手机落地到桌面正在查看的同一工作区。
14
14
  - **安全**:一个有效的一次性令牌(刷新会使旧链接失效;已接受的令牌不可复用;令牌会过期)。停止会撤销每一台已配对设备与当前令牌——已配对设备在下一次请求时被切断。当插件 `requirePairingForLan` 门开启(默认)时,每个非 loopback 的 `/api` 请求必须携带有效的已配对设备 cookie,因此二维码是进入暴露在局域网上的 dsh web 的唯一途径。
15
15
  - **实时状态**:桌面面板经 SSE 流实时镜像配对状态(等待 → 已连接 → 已断开)。
16
- - **远程更新**:侧边栏底部的下载触发按钮(手机图标左侧)打开更新面板,它探测 npm registry 上已安装的 `@linxin666/dsh-*` 全家桶版本。当存在较新版本时,面板自动执行更新(在所属 dsh profile 内 `pnpm update`;pnpm 缺失时依次回退 `corepack pnpm`、`npx --yes pnpm`,Windows 上经 `cmd.exe` 执行以解析 npm 安装的 `.cmd` shim;由仅 loopback 的 `/api/update/status` + `/api/update/run` 端点驱动)并请求重启 dsh web 以生效。本地 link 安装(开发模式)会被探测到,只报告 npm 状态而不更新。
16
+ - **远程更新**:侧边栏底部的下载触发按钮(手机图标左侧)打开更新面板,它探测 npm registry 上已安装的 `@linxin666/dsh-*` 全家桶版本。当存在较新版本时,面板自动执行更新(在所属 dsh profile 内 `pnpm update --latest`;pnpm 缺失时依次回退 `corepack pnpm`、`npx --yes pnpm`,Windows 上经 `cmd.exe` 执行以解析 npm 安装的 `.cmd` shim;由仅 loopback 的 `/api/update/status` + `/api/update/run` 端点驱动)并请求重启 dsh web 以生效。pnpm 绿色退出后还会对照 registry 复核已装版本:绿色退出但版本纹丝不动(例如 pnpm 的 `minimumReleaseAge` 门禁静默跳过同日发布的新版本)会报告为「未更新成功」并附配置指引,而不是误报成功。本地 link 安装(开发模式)会被探测到,只报告 npm 状态而不更新。
17
17
 
18
18
  ## 截图
19
19
 
package/lib/client.js CHANGED
@@ -1877,6 +1877,8 @@ window.__ModuleLoader__.load({
1877
1877
  case "not-found": return t("update.error.notFound");
1878
1878
  case "link": return t("update.error.link");
1879
1879
  case "pnpm-failed": return t("update.error.pnpmFailed", { code: String(result.exitCode ?? "?") });
1880
+ case "stale": return t("update.error.stale");
1881
+ case "verify-failed": return t("update.error.verifyFailed");
1880
1882
  default: return result.error ?? t("update.error.unknown");
1881
1883
  }
1882
1884
  }
@@ -2217,7 +2219,7 @@ window.__ModuleLoader__.load({
2217
2219
  }
2218
2220
  //#endregion
2219
2221
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/settings-card.module.css.mjs
2220
- const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Kwoi6G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save{transition:none}}";
2222
+ const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-label-error);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-label-error);outline-offset:1px;border-color:var(--dsw-alias-label-error)}.Kwoi6G_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save{transition:none}}";
2221
2223
  const tagId = "@linxin666/dsh-remote-web-ui/settings-card.module.css";
2222
2224
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2223
2225
  const tag = document.createElement("style");
@@ -2258,12 +2260,12 @@ window.__ModuleLoader__.load({
2258
2260
  //#endregion
2259
2261
  //#region src/client/PluginSettingsCard.tsx
2260
2262
  /**
2261
- * Shared chrome for the plugin settings card: a disclosure header naming the
2262
- * plugin and what its settings govern, the controls inside, and the save that
2263
- * writes them. Renders nothing while the namespace is unavailable — a
2263
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
2264
+ * the plugin and what its settings govern, the controls inside, and the save
2265
+ * that writes them. Renders nothing while the namespace is unavailable — a
2264
2266
  * deployment that does not compose the owning plugin should show no trace of
2265
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
2266
- * slice (this package must not depend on a sibling UI package).
2267
+ * it. Inlined into each consumer's client bundle; mirrors the official
2268
+ * ui-plugin-config PluginCard in a self-contained slice.
2267
2269
  */
2268
2270
  /**
2269
2271
  * Render one plugin settings card.
@@ -2275,6 +2277,7 @@ window.__ModuleLoader__.load({
2275
2277
  const { state } = props;
2276
2278
  if (!state.available) return null;
2277
2279
  const title = props.t(props.titleKey);
2280
+ const description = props.t(props.descriptionKey);
2278
2281
  const blocked = !state.dirty || state.invalid || state.saving;
2279
2282
  const cardClass = open ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
2280
2283
  if (!state.exposed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
@@ -2291,10 +2294,12 @@ window.__ModuleLoader__.load({
2291
2294
  className: settings_card_module_css_default.headText,
2292
2295
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2293
2296
  className: settings_card_module_css_default.name,
2297
+ title,
2294
2298
  children: title
2295
2299
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2296
2300
  className: settings_card_module_css_default.description,
2297
- children: props.t(props.descriptionKey)
2301
+ title: description,
2302
+ children: description
2298
2303
  })]
2299
2304
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2300
2305
  width: "14",
@@ -2332,14 +2337,17 @@ window.__ModuleLoader__.load({
2332
2337
  className: settings_card_module_css_default.headText,
2333
2338
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2334
2339
  className: settings_card_module_css_default.name,
2340
+ title,
2335
2341
  children: title
2336
2342
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2337
2343
  className: settings_card_module_css_default.description,
2338
- children: props.t(props.descriptionKey)
2344
+ title: description,
2345
+ children: description
2339
2346
  })]
2340
2347
  }),
2341
2348
  state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2342
2349
  className: settings_card_module_css_default.pending,
2350
+ title: props.t("settings.unsaved"),
2343
2351
  children: props.t("settings.unsaved")
2344
2352
  }) : null,
2345
2353
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -2367,10 +2375,10 @@ window.__ModuleLoader__.load({
2367
2375
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2368
2376
  className: settings_card_module_css_default.footer,
2369
2377
  children: [
2370
- state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2378
+ state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2371
2379
  className: settings_card_module_css_default.failed,
2372
2380
  role: "status",
2373
- children: props.t("settings.saveFailed")
2381
+ children: [props.t("settings.saveFailed"), state.failedReason ? " - " + state.failedReason : ""]
2374
2382
  }) : null,
2375
2383
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2376
2384
  type: "button",
@@ -2494,8 +2502,9 @@ window.__ModuleLoader__.load({
2494
2502
  }
2495
2503
  //#endregion
2496
2504
  //#region src/client/settings-form.ts
2497
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
2498
- function numberField(field) {
2505
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
2506
+ function numberField(field, constraints = {}) {
2507
+ const { integer = false, min } = constraints;
2499
2508
  return {
2500
2509
  field,
2501
2510
  format: (value) => typeof value === "number" ? String(value) : "",
@@ -2503,10 +2512,13 @@ window.__ModuleLoader__.load({
2503
2512
  const trimmed = text.trim();
2504
2513
  if (trimmed === "") return { kind: "clear" };
2505
2514
  const parsed = Number(trimmed);
2506
- return Number.isFinite(parsed) ? {
2515
+ if (!Number.isFinite(parsed)) return void 0;
2516
+ if (integer && !Number.isInteger(parsed)) return void 0;
2517
+ if (min !== void 0 && parsed < min) return void 0;
2518
+ return {
2507
2519
  kind: "set",
2508
2520
  value: parsed
2509
- } : void 0;
2521
+ };
2510
2522
  }
2511
2523
  };
2512
2524
  }
@@ -2530,11 +2542,13 @@ window.__ModuleLoader__.load({
2530
2542
  field,
2531
2543
  format: (value) => typeof value === "boolean" ? String(value) : "",
2532
2544
  parse: (text) => {
2533
- if (text === "true") return {
2545
+ const trimmed = text.trim();
2546
+ if (trimmed === "") return { kind: "clear" };
2547
+ if (trimmed === "true") return {
2534
2548
  kind: "set",
2535
2549
  value: true
2536
2550
  };
2537
- if (text === "false") return {
2551
+ if (trimmed === "false") return {
2538
2552
  kind: "set",
2539
2553
  value: false
2540
2554
  };
@@ -2556,6 +2570,7 @@ window.__ModuleLoader__.load({
2556
2570
  listeners = /* @__PURE__ */ new Set();
2557
2571
  saving = false;
2558
2572
  failed = false;
2573
+ failedReason;
2559
2574
  /** @param scope - the bound settings scope for this card's namespace. */
2560
2575
  constructor(scope, specs) {
2561
2576
  this.scope = scope;
@@ -2583,7 +2598,8 @@ window.__ModuleLoader__.load({
2583
2598
  dirty: plan.length > 0,
2584
2599
  invalid: plan.some((item) => item.run === void 0),
2585
2600
  saving: this.saving,
2586
- failed: this.failed
2601
+ failed: this.failed,
2602
+ ...this.failedReason === void 0 ? {} : { failedReason: this.failedReason }
2587
2603
  };
2588
2604
  }
2589
2605
  /** Read one field's state from the effective section and its staged draft. */
@@ -2624,28 +2640,50 @@ window.__ModuleLoader__.load({
2624
2640
  if (this.staged.size === 0 && !this.failed) return;
2625
2641
  this.staged.clear();
2626
2642
  this.failed = false;
2643
+ this.failedReason = void 0;
2627
2644
  this.publish();
2628
2645
  }
2629
2646
  };
2630
2647
  }
2631
2648
  /**
2632
2649
  * Write every staged edit, then re-seed from what the Host accepted.
2650
+ *
2651
+ * When the scope carries the optional batch surface (the dsh-web-ui
2652
+ * bridge scope), every planned write rides one mutation so cross-field
2653
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
2654
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
2655
+ * A field lands only when the Host reports it held the staged value; a
2656
+ * landed field's draft is dropped, a failed one stays staged for the user.
2633
2657
  * @returns settlement after every write and the read-back.
2634
2658
  */
2635
2659
  async save() {
2636
2660
  const plan = this.plan();
2637
- const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
2638
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return;
2661
+ const valid = plan.filter((item) => item.run !== void 0);
2662
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
2663
+ const plannedWrites = valid.map((item) => item.op);
2664
+ const fields = new Set(plan.map((item) => item.field));
2639
2665
  this.saving = true;
2640
2666
  this.failed = false;
2667
+ this.failedReason = void 0;
2641
2668
  this.publish();
2642
- let landed = true;
2643
- for (const write of writes) landed = await write() && landed;
2644
- if (landed) this.staged.clear();
2669
+ const landed = /* @__PURE__ */ new Set();
2670
+ const batch = this.batchedScope();
2671
+ if (batch !== void 0) {
2672
+ const result = await batch.mutate(plannedWrites);
2673
+ if (result.ok) {
2674
+ for (const field of result.fields) if (field.landed) landed.add(field.field);
2675
+ } else this.failedReason = result.message;
2676
+ } else for (const item of valid) if (await item.run()) landed.add(item.field);
2677
+ for (const field of fields) if (landed.has(field)) this.staged.delete(field);
2645
2678
  this.saving = false;
2646
- this.failed = !landed;
2679
+ this.failed = landed.size !== fields.size;
2647
2680
  this.publish();
2648
2681
  }
2682
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
2683
+ batchedScope() {
2684
+ const candidate = this.scope;
2685
+ return typeof candidate?.mutate === "function" ? candidate : void 0;
2686
+ }
2649
2687
  /**
2650
2688
  * Every staged edit a save would write. An entry whose draft is not a value
2651
2689
  * its field accepts carries no write: the form is still dirty, and the save
@@ -2660,6 +2698,10 @@ window.__ModuleLoader__.load({
2660
2698
  if (staged.clear) {
2661
2699
  if (this.stored(field)) plan.push({
2662
2700
  field,
2701
+ op: {
2702
+ field,
2703
+ op: "unset"
2704
+ },
2663
2705
  run: () => this.clear(field)
2664
2706
  });
2665
2707
  continue;
@@ -2668,14 +2710,27 @@ window.__ModuleLoader__.load({
2668
2710
  const write = spec.parse(staged.text);
2669
2711
  if (write === void 0) plan.push({
2670
2712
  field,
2713
+ op: {
2714
+ field,
2715
+ op: "unset"
2716
+ },
2671
2717
  run: void 0
2672
2718
  });
2673
2719
  else if (write.kind === "clear") plan.push({
2674
2720
  field,
2721
+ op: {
2722
+ field,
2723
+ op: "unset"
2724
+ },
2675
2725
  run: () => this.clear(field)
2676
2726
  });
2677
2727
  else plan.push({
2678
2728
  field,
2729
+ op: {
2730
+ field,
2731
+ op: "set",
2732
+ value: write.value
2733
+ },
2679
2734
  run: () => this.store(field, write.value)
2680
2735
  });
2681
2736
  }
@@ -2687,11 +2742,13 @@ window.__ModuleLoader__.load({
2687
2742
  }
2688
2743
  async store(field, value) {
2689
2744
  await this.scope.set(field, value);
2745
+ if (this.specOf(field).secret) return true;
2690
2746
  return this.userLayer()?.[field] === value;
2691
2747
  }
2692
2748
  stage(field, edit) {
2693
2749
  this.staged.set(field, edit);
2694
2750
  this.failed = false;
2751
+ this.failedReason = void 0;
2695
2752
  this.publish();
2696
2753
  }
2697
2754
  specOf(field) {
@@ -3027,6 +3084,8 @@ window.__ModuleLoader__.load({
3027
3084
  "update.error.notFound": "未找到 dsh-web-ui 聚合包安装,无法更新。",
3028
3085
  "update.error.link": "当前为本地开发模式(链接安装),无法自动更新。",
3029
3086
  "update.error.pnpmFailed": "更新执行失败(pnpm 退出码 {code}),详见下方输出。",
3087
+ "update.error.stale": "更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 \"@linxin666/*\")或 minimumReleaseAge: 0 后重新点击更新。",
3088
+ "update.error.verifyFailed": "更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。",
3030
3089
  "update.error.unknown": "更新失败,请重试。"
3031
3090
  };
3032
3091
  /** English dictionary, checked complete against the zh key set. */
@@ -3129,6 +3188,8 @@ window.__ModuleLoader__.load({
3129
3188
  "update.error.notFound": "The dsh-web-ui aggregate package is not installed; nothing to update.",
3130
3189
  "update.error.link": "Local development mode (link install) — automatic update is unavailable.",
3131
3190
  "update.error.pnpmFailed": "The update failed (pnpm exited with code {code}); see the output below.",
3191
+ "update.error.stale": "The update command ran but the installed versions did not change. Likely cause: the pnpm 11 minimumReleaseAge gate silently skips releases published less than 24 hours ago. Add minimumReleaseAgeExclude (e.g. \"@linxin666/*\") or set minimumReleaseAge: 0 in pnpm-workspace.yaml under the profile directory (~/.dsh/profiles/<profile>), then run the update again.",
3192
+ "update.error.verifyFailed": "The update command ran but the installed versions could not be verified afterwards (registry probe failed or the install path changed). Inspect the output below, or retry later.",
3132
3193
  "update.error.unknown": "Update failed; try again."
3133
3194
  };
3134
3195
  //#endregion