@linxin666/dsh-client-ui-skin-center 0.3.2 → 0.3.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.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: 42f90dbff0368a591f28ced7509f7fe522ee0f64
6
- README.zh.md: f617359c4750785da8f0731aec2cca9cacf77e7f
5
+ README.md: 04b1c524d8222926a64008329b4fb9454ab2f0c0
6
+ README.zh.md: 56cd3679ad66705e5543409086911be3bf723fbd
package/README.md CHANGED
@@ -22,9 +22,9 @@ dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center
22
22
  # From the repo (dev): dsh plugin --profile web add link:$(pwd)/packages/skins/skin-center
23
23
  ```
24
24
 
25
- `$(pwd)` is your clone of the dsh-web-ui monorepo. Only the default skin (blue-fantasy) ships inside this package; every other skin installs on demand from dsh-market.com into `$DSH_HOME/skins/<id>/`, and community skins are plain directories in the same place (no install command, no restart — reopen the card or reload to pick them up). A fresh install activates blue-fantasy by default (host seed); an update whose active skin is no longer available falls back to the stock look. The package ships `skins/blue-fantasy` only; the remaining skins stay in the repository under `skins/` as the market-build and gallery source and are never published in the package.
25
+ `$(pwd)` is your clone of the dsh-web monorepo. Only the default skin (blue-fantasy) ships inside this package; every other skin installs on demand from dsh-market.com into `$DSH_HOME/skins/<id>/`, and community skins are plain directories in the same place (no install command, no restart — reopen the card or reload to pick them up). A fresh install activates blue-fantasy by default (host seed); an update whose active skin is no longer available falls back to the stock look. The package ships `skins/blue-fantasy` only; the remaining skins stay in the repository under `skins/` as the market-build and gallery source and are never published in the package.
26
26
 
27
- skin-center is a self-contained bundle meeting the official DSH plugin standard (`dsh.bundle.patch` points to `cordis.patch.yml`); it can also be installed via git: `dsh plugin --profile web add github:<org>/dsh-web-ui#<sha>` (the `prepare` script builds `lib/` in place). pnpm ≥10 requires authorizing `allowBuilds` before installing a git dependency; a local `link:` install has no such requirement.
27
+ skin-center is a self-contained bundle meeting the official DSH plugin standard (`dsh.bundle.patch` points to `cordis.patch.yml`); it can also be installed via git: `dsh plugin --profile web add github:<org>/dsh-web#<sha>` (the `prepare` script builds `lib/` in place). pnpm ≥10 requires authorizing `allowBuilds` before installing a git dependency; a local `link:` install has no such requirement.
28
28
 
29
29
  ## Configuration
30
30
 
@@ -40,7 +40,7 @@ skin-center is a self-contained bundle meeting the official DSH plugin standard
40
40
  - All `/api/skin-center/*` routes are same-origin only: writes reject cross-site requests (Sec-Fetch-Site / Origin fence), and asset reads are contained inside each skin directory (path escapes fail closed).
41
41
  - Skin CSS is sanitized (whitelist) before serving; `patches.css` (L3) is arbitrary CSS by design and disclosed as such — it runs with full page styling power and is not a security boundary.
42
42
  - The custom-theme editor emits only fixed declarations from `CUSTOM_THEME_ALLOWED_TOKENS`, each verified against the official token registry. User input is normalized color/contrast data and never becomes a selector, URL or free-form CSS payload.
43
- - `hooks.mjs` is trusted code that shares this repository's review and release; it is served same-origin only and its import/apply errors can never take the static skin down.
43
+ - `hooks.mjs` is trusted code that shares this repository's review and release; it is served same-origin only and its import/apply errors can never take the static skin down. Hooks run for built-in skins, and for user-directory skins installed from the official DSH Market whose `dsh-market.provenance.json` sha256-pins the on-disk `skin.json` and hooks entry to the market-served bytes (verified by `src/provenance.ts`, issue #1073); a missing or mismatched provenance — hand-dropped or tampered directories — keeps the hooks facet refused while the declarative parts still load.
44
44
 
45
45
  ## Known limitations
46
46
 
@@ -48,6 +48,10 @@ skin-center is a self-contained bundle meeting the official DSH plugin standard
48
48
  - Plugins that do not output semantic attributes (and have no stable DOM anchors) receive L1 token coverage only.
49
49
  - A skin video background keeps playing regardless of the wallpaper pause-on-hidden setting; pause-on-hidden applies to the Wallpaper Engine bridge only.
50
50
 
51
+ ## Telemetry
52
+
53
+ The browser half sends one anonymous install heartbeat per UTC day to dsh-market.com: a random localStorage id plus this package's name, nothing else. The server stores only a salted hash of that id, never IP addresses, and exposes aggregate counts only. See [docs/telemetry.md](../../../docs/telemetry.md) for the full contract.
54
+
51
55
  ## Directory structure
52
56
 
53
57
  ```
@@ -57,6 +61,7 @@ skins/skin-center/
57
61
  src/core/css-safety/ # lightningcss scoping + whitelist pipeline
58
62
  src/index.ts # host entry: routes, tapIndex adapter, legacy bridge
59
63
  src/skin-repo.ts # dual-source skin catalog (built-in + $DSH_HOME/skins)
64
+ src/provenance.ts # official-market install provenance verification (hooks trust)
60
65
  src/routes-v2.ts # /api/skin-center/v2/* routes
61
66
  src/tap-index-adapter.ts # the single tapIndex adapter (anti-FOUC)
62
67
  src/active-state.ts # active-skin selection persistence
package/README.zh.md CHANGED
@@ -22,9 +22,9 @@ dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center
22
22
  # 仓库开发:dsh plugin --profile web add link:$(pwd)/packages/skins/skin-center
23
23
  ```
24
24
 
25
- `$(pwd)` 是 dsh-web-ui monorepo 的本地克隆。只有默认皮肤(蓝色幻想)随这个包发布;其余皮肤在 dsh-market.com 按需安装到 `$DSH_HOME/skins/<id>/`,社区皮肤同样是放进该目录的普通目录(均无安装命令、无需重启——重开卡片或刷新页面即收录)。新装默认激活蓝色幻想(宿主种子);升级后原激活皮肤已不可用时回退官方主题。包内只发布 `skins/blue-fantasy`;其余皮肤保留在仓库 `skins/` 下,作为市场构建与画廊的目录来源,绝不进入 npm 包。
25
+ `$(pwd)` 是 dsh-web monorepo 的本地克隆。只有默认皮肤(蓝色幻想)随这个包发布;其余皮肤在 dsh-market.com 按需安装到 `$DSH_HOME/skins/<id>/`,社区皮肤同样是放进该目录的普通目录(均无安装命令、无需重启——重开卡片或刷新页面即收录)。新装默认激活蓝色幻想(宿主种子);升级后原激活皮肤已不可用时回退官方主题。包内只发布 `skins/blue-fantasy`;其余皮肤保留在仓库 `skins/` 下,作为市场构建与画廊的目录来源,绝不进入 npm 包。
26
26
 
27
- 皮肤中心是符合官方 DSH 插件标准的自包含 bundle(`dsh.bundle.patch` 指向 `cordis.patch.yml`);也可经 git 安装:`dsh plugin --profile web add github:<org>/dsh-web-ui#<sha>`(`prepare` 脚本就地构建 `lib/`)。pnpm ≥10 安装 git 依赖前需授权 `allowBuilds`;本地 `link:` 安装无此要求。
27
+ 皮肤中心是符合官方 DSH 插件标准的自包含 bundle(`dsh.bundle.patch` 指向 `cordis.patch.yml`);也可经 git 安装:`dsh plugin --profile web add github:<org>/dsh-web#<sha>`(`prepare` 脚本就地构建 `lib/`)。pnpm ≥10 安装 git 依赖前需授权 `allowBuilds`;本地 `link:` 安装无此要求。
28
28
 
29
29
  ## 配置
30
30
 
@@ -40,7 +40,7 @@ dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center
40
40
  - 所有 `/api/skin-center/*` 路由仅接受同源请求:写操作拒绝跨站请求(Sec-Fetch-Site / Origin 围栏),资产读取限定在各皮肤目录之内(路径逃逸 fail-closed)。
41
41
  - 皮肤 CSS 在服务前经白名单净化;`patches.css`(L3)按设计就是任意 CSS 并如实公示——它拥有完整页面样式能力,不构成安全边界。
42
42
  - 自定义主题编辑器只会从 `CUSTOM_THEME_ALLOWED_TOKENS` 生成固定声明,且每个 token 都对照官方 token 注册表校验。用户输入只作为规范化后的颜色/对比度数据,不会成为选择器、URL 或自由 CSS 载荷。
43
- - `hooks.mjs` 是与本仓库同审同发的受信代码,仅同源 serve,其 import/apply 错误永远不会拖垮静态皮肤。
43
+ - `hooks.mjs` 是与本仓库同审同发的受信代码,仅同源 serve,其 import/apply 错误永远不会拖垮静态皮肤。hooks 对内置皮肤放行;对用户目录皮肤,仅当其从官方 DSH 市场安装且 `dsh-market.provenance.json` 以 sha256 钉住磁盘上的 `skin.json` 与 hooks 入口字节时放行(由 `src/provenance.ts` 校验,issue #1073);provenance 缺失或不匹配——手工投放或被篡改的目录——hooks facet 保持拒绝,声明式部分仍正常加载。
44
44
 
45
45
  ## 已知限制
46
46
 
@@ -48,6 +48,10 @@ dsh plugin --profile web add @linxin666/dsh-client-ui-skin-center
48
48
  - 不输出语义属性(且无稳定 DOM 锚点)的插件只享受 L1 token 覆盖。
49
49
  - 皮肤视频背景不受壁纸「隐藏时暂停」设置影响;该设置仅作用于 Wallpaper Engine 桥。
50
50
 
51
+ ## 数据遥测
52
+
53
+ 浏览器半区每个 UTC 日向 dsh-market.com 发送一次匿名安装心跳:仅含一个 localStorage 随机 ID 与本包名,无其他数据。服务端只存储该 ID 的加盐哈希,不存 IP,且只暴露聚合计数。完整契约见 [docs/telemetry.md](../../../docs/telemetry.md)。
54
+
51
55
  ## 目录结构
52
56
 
53
57
  ```
@@ -57,6 +61,7 @@ skins/skin-center/
57
61
  src/core/css-safety/ # lightningcss 作用域限定 + 白名单管线
58
62
  src/index.ts # host 入口:路由、tapIndex 适配器、旧版迁移桥
59
63
  src/skin-repo.ts # 双来源皮肤目录册(内置 + $DSH_HOME/skins)
64
+ src/provenance.ts # 官方市场安装 provenance 校验(hooks 信任)
60
65
  src/routes-v2.ts # /api/skin-center/v2/* 路由
61
66
  src/tap-index-adapter.ts # 单一 tapIndex 适配器(防 FOUC)
62
67
  src/active-state.ts # 活动皮肤选择持久化
@@ -37,6 +37,24 @@ manifests would be rejected by their own validator. Optional legal metadata
37
37
  (`license`, `licenseUrl`, `noticeUrl`, `sourceUrl`, `attribution`) is
38
38
  first-class in v2.
39
39
 
40
+ ## Hooks trust model
41
+
42
+ `hooks.mjs` is trusted executable code that shares this repository's review
43
+ and release. It is served verbatim over `GET /skins/<id>/hooks.mjs` only for:
44
+
45
+ - **built-in skins** — shipped inside the skin-center npm package, same
46
+ review and release by definition;
47
+ - **official-market installs** — user-directory skins carrying a
48
+ `dsh-market.provenance.json` whose sha256 pins the on-disk `skin.json`
49
+ and hooks entry to the bytes the official DSH Market served (the market
50
+ content is built from this repository, so a hash match means the bytes
51
+ are the reviewed ones). Verification lives in `src/provenance.ts` and
52
+ fails closed: any missing, foreign-source or mismatched provenance keeps
53
+ the hooks facet refused with a catalog warning while the declarative
54
+ parts (skin.css / patches.css / assets) still load (issue #1073).
55
+
56
+ Locally dropped or third-party skin directories never run hooks.
57
+
40
58
  ## Loader-side rules (pinned here, enforced in M2)
41
59
 
42
60
  - All skin CSS is force-scoped under `html[data-dsh-skin="<id>"]` by the
@@ -111,7 +111,7 @@ family / 插件区域:
111
111
  | `git-graph` | dsh-git-graph | slot entry id `git-graph`;`[data-gitgraph-chip-anchor]` / `[data-gitgraph-dialog]` |
112
112
  | `pet` | dsh-pet | `[data-dsh-pet-root]`;一级设置分区 settings.section id `pet`(只列内置与已安装宠物) |
113
113
  | `remote-web-ui` | dsh-remote-web-ui | slot entry id `remote-web-ui` |
114
- | `web-ui-settings` | dsh-web-ui-settings | settings.section id `web-ui-plugins` |
114
+ | `web-ui-settings` | dsh-web-settings | settings.section id `web-ui-plugins` |
115
115
  | `skill-explorer` | dsh-skill-explorer | `[data-dsh-skill-explorer-view]` / `[data-dsh-skill-explorer-entry]` |
116
116
  | `doctor` | dsh-doctor | web-ui.plugin.item 槽 entry id `doctor`(设置 → Web UI 插件 → Doctor 卡片);卡片内 `[data-dsh-plugin="doctor"]` |
117
117
  | `aionui-panel` | dsh-aionui-panel(停更) | dock entry id `aionui-*` |
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://schemas.linxin666.org/dsh-skin/v2.json",
4
4
  "title": "DSH Skin Manifest v2",
5
- "description": "Declarative manifest for a dsh-web-ui skin asset directory. Skins are pure assets; the skin-center package is the only loader/renderer. Validators must use this local schema copy and never fetch the $id URL.",
5
+ "description": "Declarative manifest for a dsh-web skin asset directory. Skins are pure assets; the skin-center package is the only loader/renderer. Validators must use this local schema copy and never fetch the $id URL.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
package/lib/client.js CHANGED
@@ -570,6 +570,7 @@ window.__ModuleLoader__.load({
570
570
  volumeValue = 100;
571
571
  dimValue = 25;
572
572
  blurValue = 0;
573
+ opacityValue = 100;
573
574
  dirsValue = [];
574
575
  listeners = /* @__PURE__ */ new Set();
575
576
  scope;
@@ -598,13 +599,16 @@ window.__ModuleLoader__.load({
598
599
  /** Detached frame-capture video; released on error/abort/loadeddata and on
599
600
  * teardown so it never keeps buffering the source file. */
600
601
  captureVideo = null;
602
+ /** Guard flag: suppresses readAll during applyThemeDefaults scope writes
603
+ * to prevent mid-write listener cascades from resetting values. */
604
+ seeding = false;
601
605
  constructor(scope, options = {}) {
602
606
  this.scope = scope;
603
607
  this.options = options;
604
608
  this.doc = options.doc ?? document;
605
609
  this.readAll();
606
610
  this.unsubscribe = scope.subscribe(() => {
607
- if (this.disposed) return;
611
+ if (this.disposed || this.seeding) return;
608
612
  this.readAll();
609
613
  if (this.enabledValue && this.selectionValue && (!this.applied || this.applied.id !== this.selectionValue)) this.fetchAndSync();
610
614
  else {
@@ -625,6 +629,7 @@ window.__ModuleLoader__.load({
625
629
  });
626
630
  this.mountObserver.observe(this.doc.body, { childList: true });
627
631
  }
632
+ this.applyThemeDefaults();
628
633
  if (this.enabledValue && this.selectionValue) this.fetchAndSync();
629
634
  }
630
635
  fetchAndSync() {
@@ -722,6 +727,7 @@ window.__ModuleLoader__.load({
722
727
  fit = () => this.fitValue;
723
728
  dim = () => this.dimValue;
724
729
  wallpaperBlur = () => this.blurValue;
730
+ wallpaperOpacity = () => this.opacityValue;
725
731
  pauseOnHidden = () => this.pauseOnHiddenValue;
726
732
  sound = () => this.soundValue;
727
733
  volume = () => this.volumeValue;
@@ -781,6 +787,12 @@ window.__ModuleLoader__.load({
781
787
  this.publish();
782
788
  this.scope.set("wallpaperBlur", this.blurValue);
783
789
  }
790
+ setOpacity(value) {
791
+ this.opacityValue = clamp(value, 0, 100);
792
+ this.render();
793
+ this.publish();
794
+ this.scope.set("wallpaperOpacity", this.opacityValue);
795
+ }
784
796
  setPauseOnHidden(value) {
785
797
  this.pauseOnHiddenValue = value;
786
798
  this.publish();
@@ -860,6 +872,32 @@ window.__ModuleLoader__.load({
860
872
  this.doc.removeEventListener("keydown", this.onFirstGesture);
861
873
  this.teardownLayers();
862
874
  }
875
+ /**
876
+ * Seed dim and opacity with theme-tuned values when neither has been
877
+ * explicitly set (both are at schema defaults: dim=25, opacity=100).
878
+ * This runs once at construction so a fresh wallpaper install gets
879
+ * readable defaults for the active theme. Users who have already
880
+ * adjusted either slider keep their values (#1051).
881
+ */
882
+ applyThemeDefaults() {
883
+ const snapshot = this.scope.getSnapshot();
884
+ const value = snapshot.value ?? {};
885
+ if (value.dim !== void 0 || value.wallpaperOpacity !== void 0) return;
886
+ if (snapshot.writable === false) return;
887
+ if ((this.options.themeGet !== void 0 ? this.options.themeGet() : this.doc.body?.hasAttribute("data-ds-dark-theme") ? "dark" : "light") === "light") {
888
+ this.dimValue = 0;
889
+ this.opacityValue = 40;
890
+ } else {
891
+ this.dimValue = 40;
892
+ this.opacityValue = 100;
893
+ }
894
+ this.seeding = true;
895
+ try {
896
+ this.scope.set("dim", this.dimValue);
897
+ this.scope.set("wallpaperOpacity", this.opacityValue);
898
+ } catch {}
899
+ this.seeding = false;
900
+ }
863
901
  readAll() {
864
902
  const value = this.scope.getSnapshot().value ?? {};
865
903
  this.enabledValue = typeof value.enabled === "boolean" ? value.enabled : true;
@@ -872,6 +910,7 @@ window.__ModuleLoader__.load({
872
910
  this.volumeValue = typeof value.volume === "number" && Number.isFinite(value.volume) ? clamp(value.volume, 0, 100) : 100;
873
911
  this.dimValue = typeof value.dim === "number" && Number.isFinite(value.dim) ? clamp(value.dim, 0, 90) : 25;
874
912
  this.blurValue = typeof value.wallpaperBlur === "number" && Number.isFinite(value.wallpaperBlur) ? clamp(value.wallpaperBlur, 0, 60) : 0;
913
+ this.opacityValue = typeof value.wallpaperOpacity === "number" && Number.isFinite(value.wallpaperOpacity) ? clamp(value.wallpaperOpacity, 0, 100) : 100;
875
914
  this.dirsValue = Array.isArray(value.weLibraryDirs) ? value.weLibraryDirs.filter((d) => typeof d === "string" && d.trim() !== "") : [];
876
915
  }
877
916
  /** Resume a policy-blocked video on the first user gesture (#580). */
@@ -914,9 +953,16 @@ window.__ModuleLoader__.load({
914
953
  this.rootNeutralizer.dataset.dshWallpaperRoot = "";
915
954
  this.rootNeutralizer.textContent = `
916
955
  [id="root"] { background: transparent; }
917
- html[data-dsh-wallpaper-active],
956
+ /* Wallpaper opacity backdrop (#1051): light-mode fades to white,
957
+ dark-mode fades to black. :has() tracks theme switches live. */
958
+ html[data-dsh-wallpaper-active] {
959
+ background-color: white !important;
960
+ background-image: none !important;
961
+ }
962
+ html[data-dsh-wallpaper-active]:has(body[data-ds-dark-theme]) {
963
+ background-color: black !important;
964
+ }
918
965
  body[data-dsh-wallpaper-active],
919
- html[data-dsh-skin][data-dsh-wallpaper-active],
920
966
  html[data-dsh-skin][data-dsh-wallpaper-active] body,
921
967
  html[data-dsh-skin] body[data-dsh-wallpaper-active],
922
968
  body[data-dsh-wallpaper-active][data-ds-dark-theme],
@@ -985,6 +1031,7 @@ window.__ModuleLoader__.load({
985
1031
  const blur = this.blurValue > 0 ? "blur(" + String(this.blurValue) + "px)" : "";
986
1032
  this.mediaLayer.style.filter = blur;
987
1033
  this.mediaLayer.style.transform = this.blurValue > 0 ? "scale(1.05)" : "";
1034
+ this.mediaLayer.style.opacity = this.opacityValue < 100 ? String(this.opacityValue / 100) : "";
988
1035
  this.scrimLayer.style.background = "rgba(0, 0, 0, " + String(this.dimValue / 100) + ")";
989
1036
  }
990
1037
  /** Push the current sizing mode onto the mounted media element. */
@@ -1477,6 +1524,7 @@ window.__ModuleLoader__.load({
1477
1524
  const fit = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.fit);
1478
1525
  const dim = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.dim);
1479
1526
  const blur = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.wallpaperBlur);
1527
+ const opacity = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.wallpaperOpacity);
1480
1528
  const pauseOnHidden = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.pauseOnHidden);
1481
1529
  const sound = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.sound);
1482
1530
  const volume = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.volume);
@@ -1485,6 +1533,7 @@ window.__ModuleLoader__.load({
1485
1533
  const dirs = (0, react.useSyncExternalStore)(wallpaper.subscribe, wallpaper.dirs);
1486
1534
  const [shownDim, setShownDim] = useLiveValue$1(dim);
1487
1535
  const [shownBlur, setShownBlur] = useLiveValue$1(blur);
1536
+ const [shownOpacity, setShownOpacity] = useLiveValue$1(opacity);
1488
1537
  const [shownVolume, setShownVolume] = useLiveValue$1(volume);
1489
1538
  const [dirInput, setDirInput] = (0, react.useState)("");
1490
1539
  const [picking, setPicking] = (0, react.useState)(false);
@@ -1723,6 +1772,30 @@ window.__ModuleLoader__.load({
1723
1772
  wallpaper.setDim(value);
1724
1773
  }
1725
1774
  }),
1775
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1776
+ className: skin_center_module_css_default.backgroundHead,
1777
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1778
+ className: skin_center_module_css_default.backgroundLabel,
1779
+ children: t("wallpaperOpacity")
1780
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1781
+ className: skin_center_module_css_default.backgroundValue,
1782
+ "aria-hidden": "true",
1783
+ children: [shownOpacity, "%"]
1784
+ })]
1785
+ }),
1786
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SliderControl, {
1787
+ className: skin_center_module_css_default.backgroundRange,
1788
+ min: 0,
1789
+ max: 100,
1790
+ step: 5,
1791
+ value: opacity,
1792
+ ariaValuetext: shownOpacity + "%",
1793
+ ariaLabel: t("wallpaperOpacity"),
1794
+ onChanging: setShownOpacity,
1795
+ onChange: (value) => {
1796
+ wallpaper.setOpacity(value);
1797
+ }
1798
+ }),
1726
1799
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1727
1800
  className: skin_center_module_css_default.backgroundHead,
1728
1801
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2599,6 +2672,36 @@ window.__ModuleLoader__.load({
2599
2672
  };
2600
2673
  /** The fields normalize/sanitize know about; unknown keys are dropped. */
2601
2674
  const SKIN_BACKGROUND_FIELDS = Object.keys(SKIN_BACKGROUND_DEFAULTS);
2675
+ function clampInt(value, min, max) {
2676
+ return Math.max(min, Math.min(max, Math.round(value)));
2677
+ }
2678
+ const RANGES = {
2679
+ backgroundOpacity: [0, 100],
2680
+ backgroundBlurEmpty: [0, 20],
2681
+ backgroundBlurContent: [0, 20],
2682
+ inputCardBlur: [0, 20],
2683
+ bubbleOpacity: [0, 100]
2684
+ };
2685
+ function isRecord$1(value) {
2686
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2687
+ }
2688
+ /**
2689
+ * Lenient normalization for stored/legacy data: unknown keys and wrongly
2690
+ * typed fields are dropped, numeric fields are clamped into range. Never
2691
+ * fails; a non-object input yields an empty config.
2692
+ */
2693
+ function normalizeSkinBackground(value) {
2694
+ if (!isRecord$1(value)) return {};
2695
+ const out = {};
2696
+ if (typeof value.enabled === "boolean") out.enabled = value.enabled;
2697
+ for (const field of Object.keys(RANGES)) {
2698
+ const raw = value[field];
2699
+ if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
2700
+ const [min, max] = RANGES[field];
2701
+ out[field] = clampInt(raw, min, max);
2702
+ }
2703
+ return out;
2704
+ }
2602
2705
  /** Fill every absent field from {@link SKIN_BACKGROUND_DEFAULTS}. */
2603
2706
  function resolveSkinBackground(value) {
2604
2707
  return {
@@ -2653,7 +2756,7 @@ window.__ModuleLoader__.load({
2653
2756
  /**
2654
2757
  * Selector for a conversation message row inside the shell's center column.
2655
2758
  * Official shell message rows carry `data-chat-anchor-key`; the
2656
- * `data-pane="conversation"` attribute is stamped by the dsh-web-ui-all compat
2759
+ * `data-pane="conversation"` attribute is stamped by the dsh-web-all compat
2657
2760
  * shim on the center column, where the _userRow / _compactionRow /
2658
2761
  * _contextRow / _turnErrorRow suffixes are CSS-module message-row classes
2659
2762
  * (hash prefix varies, suffix is stable).
@@ -2914,6 +3017,62 @@ window.__ModuleLoader__.load({
2914
3017
  }
2915
3018
  };
2916
3019
  //#endregion
3020
+ //#region src/core/background-scope.ts
3021
+ /**
3022
+ * Safe reconciliation helpers for the legacy skin-background settings scope.
3023
+ *
3024
+ * The scope's resolved value contains schema defaults, while its raw user layer
3025
+ * contains only fields explicitly stored by the user. The v2 active-state
3026
+ * document is authoritative, so a scope update may merge only those raw fields
3027
+ * into the live background values.
3028
+ */
3029
+ function isRecord(value) {
3030
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3031
+ }
3032
+ /** Return the known, explicitly stored skin-background fields from a user layer. */
3033
+ function extractSkinBackgroundUserLayer(value) {
3034
+ if (!isRecord(value)) return null;
3035
+ const normalized = normalizeSkinBackground(value);
3036
+ const knownFields = Object.keys(normalized);
3037
+ if (knownFields.length === 0) return null;
3038
+ const owned = {};
3039
+ for (const field of knownFields) owned[field] = normalized[field];
3040
+ return owned;
3041
+ }
3042
+ /**
3043
+ * Build a v2-safe patch from the raw user layer. Absent fields, including
3044
+ * schema-default fields absent from the user layer, never enter the patch.
3045
+ */
3046
+ function skinBackgroundUserPatch(current, user) {
3047
+ const userConfig = extractSkinBackgroundUserLayer(user);
3048
+ if (userConfig === null) return null;
3049
+ const patch = {};
3050
+ for (const field of SKIN_BACKGROUND_FIELDS) {
3051
+ if (!Object.hasOwn(userConfig, field)) continue;
3052
+ const value = userConfig[field];
3053
+ if (value === void 0 || value === current[field]) continue;
3054
+ patch[field] = value;
3055
+ }
3056
+ return Object.keys(patch).length === 0 ? null : patch;
3057
+ }
3058
+ /**
3059
+ * Accept a scope publication only when its namespace revision is new. The
3060
+ * caller records the returned revision even when there is no user patch, so a
3061
+ * later mirror publication with the same revision stays inert.
3062
+ */
3063
+ function reconcileSkinBackgroundScope(current, snapshot, lastRevision) {
3064
+ if (snapshot.revision === void 0 || snapshot.revision === lastRevision) return {
3065
+ accepted: false,
3066
+ revision: lastRevision,
3067
+ patch: null
3068
+ };
3069
+ return {
3070
+ accepted: true,
3071
+ revision: snapshot.revision,
3072
+ patch: skinBackgroundUserPatch(current, snapshot.user)
3073
+ };
3074
+ }
3075
+ //#endregion
2917
3076
  //#region src/client/locales.ts
2918
3077
  const en = {
2919
3078
  title: "Skin Center",
@@ -2967,6 +3126,7 @@ window.__ModuleLoader__.load({
2967
3126
  wallpaperFitFill: "Stretch",
2968
3127
  wallpaperClear: "Turn off wallpaper",
2969
3128
  wallpaperDim: "Wallpaper dimming",
3129
+ wallpaperOpacity: "Wallpaper opacity",
2970
3130
  wallpaperBlur: "Wallpaper blur",
2971
3131
  wallpaperPauseHidden: "Pause when window hidden",
2972
3132
  wallpaperSound: "Wallpaper sound",
@@ -3008,7 +3168,7 @@ window.__ModuleLoader__.load({
3008
3168
  customThemeSaveFailed: "Could not save custom theme changes."
3009
3169
  };
3010
3170
  const zh = {
3011
- title: "皮肤中心",
3171
+ title: "皮肤",
3012
3172
  cardDescription: "在 GUI 内即时试穿任意皮肤,退出即完全还原;应用一键完成并自动刷新。",
3013
3173
  enabled: "启用皮肤中心",
3014
3174
  enabledHint: "关闭后停用试穿、应用与背景控件,重新打开即恢复。",
@@ -3059,6 +3219,7 @@ window.__ModuleLoader__.load({
3059
3219
  wallpaperFitFill: "拉伸铺满",
3060
3220
  wallpaperClear: "关闭壁纸",
3061
3221
  wallpaperDim: "壁纸暗化",
3222
+ wallpaperOpacity: "壁纸不透明度",
3062
3223
  wallpaperBlur: "壁纸模糊",
3063
3224
  wallpaperPauseHidden: "窗口隐藏时暂停",
3064
3225
  wallpaperSound: "壁纸声音",
@@ -4384,6 +4545,76 @@ window.__ModuleLoader__.load({
4384
4545
  }
4385
4546
  };
4386
4547
  //#endregion
4548
+ //#region src/client/telemetry.ts
4549
+ const VISITOR_KEY = "dsh-web-ui-telemetry-visitor";
4550
+ const DAY_KEY_PREFIX = "dsh-web-ui-telemetry-day:";
4551
+ const ENDPOINT = "https://dsh-market.com/api/telemetry/event";
4552
+ /** The building package's version, when the bundle carries it. */
4553
+ function bakedVersion() {
4554
+ try {
4555
+ return "0.3.3";
4556
+ } catch {
4557
+ return;
4558
+ }
4559
+ }
4560
+ /** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
4561
+ function visitorId() {
4562
+ try {
4563
+ const existing = localStorage.getItem(VISITOR_KEY);
4564
+ if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing;
4565
+ const fresh = crypto.randomUUID().replaceAll("-", "");
4566
+ localStorage.setItem(VISITOR_KEY, fresh);
4567
+ return fresh;
4568
+ } catch {
4569
+ return null;
4570
+ }
4571
+ }
4572
+ /** Drop stale per-day dedup keys so localStorage does not grow forever. */
4573
+ function pruneDayKeys(today) {
4574
+ try {
4575
+ for (let index = localStorage.length - 1; index >= 0; index -= 1) {
4576
+ const key = localStorage.key(index);
4577
+ if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) localStorage.removeItem(key);
4578
+ }
4579
+ } catch {}
4580
+ }
4581
+ /**
4582
+ * Fire the daily heartbeat for the given items at most once per UTC day per
4583
+ * browser. Never throws and never blocks the caller. Items without an explicit
4584
+ * version inherit the bundle's baked build version.
4585
+ */
4586
+ function reportDailyHeartbeat(items) {
4587
+ try {
4588
+ if (items.length === 0) return;
4589
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4590
+ if (navigator.webdriver) return;
4591
+ if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return;
4592
+ const visitor = visitorId();
4593
+ if (visitor === null) return;
4594
+ pruneDayKeys(today);
4595
+ const payloadItems = items.map((item) => {
4596
+ const out = { name: item.name };
4597
+ const version = item.version ?? bakedVersion();
4598
+ if (version !== void 0) out.version = version;
4599
+ if (item.channel !== void 0) out.channel = item.channel;
4600
+ return out;
4601
+ });
4602
+ const body = JSON.stringify({
4603
+ kind: "heartbeat",
4604
+ visitor,
4605
+ items: payloadItems
4606
+ });
4607
+ fetch(ENDPOINT, {
4608
+ method: "POST",
4609
+ headers: { "content-type": "application/json" },
4610
+ body,
4611
+ keepalive: true
4612
+ }).then((response) => {
4613
+ if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, "1");
4614
+ }).catch(() => {});
4615
+ } catch {}
4616
+ }
4617
+ //#endregion
4387
4618
  //#region src/client/index.ts
4388
4619
  /** Locale namespace owned by this plugin. */
4389
4620
  const NS = "skinCenter";
@@ -4397,12 +4628,36 @@ window.__ModuleLoader__.load({
4397
4628
  "remote",
4398
4629
  "workspaces"
4399
4630
  ];
4631
+ /** Self-report item for the install heartbeat. */
4632
+ const SELF_ITEM = [{ name: "@linxin666/dsh-client-ui-skin-center" }];
4633
+ /**
4634
+ * Beat the install heartbeat (docs/telemetry.md), enriching it with the
4635
+ * installed skin inventory (skin:<id> + version + channel) once the v2
4636
+ * catalog answers. Offline or pre-boot the beat stays package-only.
4637
+ */
4638
+ function beatHeartbeat() {
4639
+ reportDailyHeartbeat(SELF_ITEM);
4640
+ fetch("/api/skin-center/v2/catalog").then((res) => res.ok ? res.json() : null).then((catalog) => {
4641
+ if (!catalog || !Array.isArray(catalog.skins)) return;
4642
+ const items = [...SELF_ITEM];
4643
+ for (const skin of catalog.skins) {
4644
+ const id = skin && skin.manifest && typeof skin.manifest.id === "string" ? skin.manifest.id : "";
4645
+ if (!id) continue;
4646
+ const item = { name: "skin:" + id };
4647
+ if (typeof skin.manifest.version === "string") item.version = skin.manifest.version;
4648
+ if (typeof skin.channel === "string") item.channel = skin.channel;
4649
+ items.push(item);
4650
+ }
4651
+ reportDailyHeartbeat(items.slice(0, 64));
4652
+ }).catch(() => {});
4653
+ }
4400
4654
  /**
4401
4655
  * Register the skin-center dictionaries, the body scope attribute, and the
4402
4656
  * Skin Center as a first-level settings section.
4403
4657
  * @param ctx - client root context.
4404
4658
  */
4405
4659
  function apply(ctx) {
4660
+ beatHeartbeat();
4406
4661
  ctx.effect(() => {
4407
4662
  try {
4408
4663
  return ctx.locale.register(NS, {
@@ -4448,19 +4703,37 @@ window.__ModuleLoader__.load({
4448
4703
  const scopeConfig = () => {
4449
4704
  const value = backgroundScope.getSnapshot().value;
4450
4705
  if (value === void 0 || value === null) return null;
4451
- if (!SKIN_BACKGROUND_FIELDS.some((field) => value[field] !== void 0)) return null;
4452
4706
  return value;
4453
4707
  };
4708
+ let v2Loaded = false;
4709
+ let lastScopeRevision = backgroundScope.getSnapshot().revision;
4454
4710
  const background = new BackgroundController(scopeConfig(), persistBackground);
4455
- fetch(V2_ACTIVE_URL).then((res) => res.ok ? res.json() : null).then((body) => {
4456
- if (body) background.init(body.background ?? null);
4457
- }).catch(() => {});
4458
- ctx.effect(() => backgroundScope.subscribe(() => {
4459
- const next = scopeConfig();
4460
- if (next === null) return;
4461
- background.init(next);
4711
+ const reconcileScope = () => {
4712
+ if (!v2Loaded) return;
4713
+ const snapshot = backgroundScope.getSnapshot();
4714
+ const result = reconcileSkinBackgroundScope(background.snapshot(), {
4715
+ revision: snapshot.revision,
4716
+ user: snapshot.user
4717
+ }, lastScopeRevision);
4718
+ if (!result.accepted) return;
4719
+ lastScopeRevision = result.revision;
4720
+ if (result.patch === null) return;
4721
+ const current = background.snapshot();
4722
+ background.init({
4723
+ ...current,
4724
+ ...result.patch
4725
+ });
4462
4726
  persistBackground(background.snapshot());
4463
- }), "ui-skin-center: background scope sync");
4727
+ };
4728
+ fetch(V2_ACTIVE_URL).then((res) => res.ok ? res.json() : null).then((body) => {
4729
+ v2Loaded = true;
4730
+ if (body?.background) background.init(body.background);
4731
+ reconcileScope();
4732
+ }).catch(() => {
4733
+ v2Loaded = true;
4734
+ reconcileScope();
4735
+ });
4736
+ ctx.effect(() => backgroundScope.subscribe(reconcileScope), "ui-skin-center: background scope sync");
4464
4737
  ctx.effect(() => () => {
4465
4738
  flushBackground();
4466
4739
  background.dispose();
@@ -4509,6 +4782,7 @@ window.__ModuleLoader__.load({
4509
4782
  fit: () => wallpaper.fit(),
4510
4783
  dim: () => wallpaper.dim(),
4511
4784
  wallpaperBlur: () => wallpaper.wallpaperBlur(),
4785
+ wallpaperOpacity: () => wallpaper.wallpaperOpacity(),
4512
4786
  pauseOnHidden: () => wallpaper.pauseOnHidden(),
4513
4787
  sound: () => wallpaper.sound(),
4514
4788
  volume: () => wallpaper.volume(),
@@ -4524,6 +4798,7 @@ window.__ModuleLoader__.load({
4524
4798
  setFit: (fit) => wallpaper.setFit(fit),
4525
4799
  setDim: (value) => wallpaper.setDim(value),
4526
4800
  setBlur: (value) => wallpaper.setBlur(value),
4801
+ setOpacity: (value) => wallpaper.setOpacity(value),
4527
4802
  setPauseOnHidden: (value) => wallpaper.setPauseOnHidden(value),
4528
4803
  setSound: (value) => wallpaper.setSound(value),
4529
4804
  setVolume: (value) => wallpaper.setVolume(value),