wallpaper-engine-dsh 0.1.4 → 0.1.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Weilv-D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Restart `dsh web`, then open **Settings → General → Wallpaper Engine**.
34
34
 
35
35
  1. Pick a wallpaper from the thumbnail grid — it fades in behind the app. Use the search box to narrow large libraries.
36
36
  2. **Pause/Play** controls video playback; **Close** fades the wallpaper out; **Refresh** rescans the library (new workshop subscriptions appear without reloading the page).
37
- 3. Tune the blend with the sliders. On busy wallpapers, raise **Scrim** and **Borders** until text is comfortable; the UI follows DSH's light/dark theme automatically.
37
+ 3. Tune the blend with the sliders. On busy wallpapers, raise **Scrim** and **Borders** until text is comfortable; the UI follows DSH's light/dark theme automatically. The **Fit/Align** row sets how video and still wallpapers sit on the canvas (cover/contain/stretch/original, five anchor points).
38
38
  4. Create rotation lists with **New**, fill them from the grid or import a WE playlist, then enable **Auto-rotate**.
39
39
  5. The resource row shows the current frame rate and offers two auto-pause switches. `prefers-reduced-motion` starts videos paused.
40
40
  6. The Language control switches the picker between Chinese and English; on Auto it follows the shell setting.
@@ -49,9 +49,9 @@ The bundle has two halves:
49
49
  - `GET /we-background/inventory[?refresh=1]`
50
50
  - `GET /we-background/media/<token>[/asset…]` — with HTTP Range support for video seeking; web wallpapers can fetch their bundled sub-assets
51
51
  - `GET /we-background/preview/<token>`
52
- - A **browser plugin** renders the chosen wallpaper on a fixed layer behind the app frame, contributes the settings UI, and keeps every effect on DSH design tokens so themes apply cleanly.
52
+ - A **browser plugin** renders the chosen wallpaper on a fixed layer behind the app frame, contributes the settings UI, and keeps every effect on DSH design tokens so themes apply cleanly. Wallpaper blur is clipped overscan (no content warping) with a whisper of dither noise to keep gradients band-free; a video the browser cannot decode falls back to the wallpaper's preview image.
53
53
 
54
- The host answers from a 30-second inventory cache; discovery is resolved once at startup and reused. The bundle registers no model tools and adds no prompt text.
54
+ The host answers from a 30-second inventory cache with stable per-wallpaper tokens (an in-flight stream is never killed by a background rebuild); discovery is resolved asynchronously and cached for ten minutes. The bundle registers no model tools and adds no prompt text.
55
55
 
56
56
  ## Limitations
57
57
 
package/README.zh.md CHANGED
@@ -34,7 +34,7 @@ dsh plugin --profile web add link:<克隆文件夹的绝对路径>
34
34
 
35
35
  1. 从缩略图网格选择壁纸,它会在界面背后淡入。壁纸库较大时用搜索框快速定位。
36
36
  2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库,新订阅的创意工坊壁纸无需刷新页面即可出现。
37
- 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。
37
+ 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布/对齐** 一行设置视频与静态壁纸在画布上的摆放(填充/适应/拉伸/原始,五个锚点)。
38
38
  4. 用 **新建** 创建轮播列表,从网格挑选或导入 WE 播放列表,然后开启 **自动轮转**。
39
39
  5. 资源行显示当前帧率,并提供两个自动暂停开关。系统开启 `prefers-reduced-motion` 时视频默认暂停。
40
40
  6. "语言"控件在中英文之间切换面板;选"自动"则跟随 DSH 的语言设置。
@@ -49,9 +49,9 @@ bundle 分为两半:
49
49
  - `GET /we-background/inventory[?refresh=1]`
50
50
  - `GET /we-background/media/<token>[/资源…]` —— 支持 HTTP Range,视频可拖动进度;网页壁纸可加载其打包的子资源
51
51
  - `GET /we-background/preview/<token>`
52
- - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。
52
+ - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。
53
53
 
54
- 清单由宿主缓存 30 秒;安装发现只解析一次。本插件不注册模型工具,不占用 prompt。
54
+ 清单由宿主缓存 30 秒,媒体令牌按壁纸稳定复用(后台重建不会掐断播放中的流);安装发现异步执行并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
55
55
 
56
56
  ## 限制
57
57
 
package/lib/client.js CHANGED
@@ -102,6 +102,8 @@ window.__ModuleLoader__.load({
102
102
  confirmDelete: (name) => "删除轮播列表「" + name + "」?",
103
103
  defaultGroupName: (n) => "轮播列表 " + n,
104
104
  defaultGroupBase: "轮播列表",
105
+ iframeTitle: "Wallpaper Engine 网页壁纸",
106
+ decodeFallback: "媒体无法解码,已改用预览图",
105
107
  },
106
108
  en: {
107
109
  settingsLabel: "Wallpaper Engine",
@@ -178,6 +180,8 @@ window.__ModuleLoader__.load({
178
180
  confirmDelete: (name) => "Delete rotation list \"" + name + "\"?",
179
181
  defaultGroupName: (n) => "Rotation list " + n,
180
182
  defaultGroupBase: "Rotation list",
183
+ iframeTitle: "Wallpaper Engine web wallpaper",
184
+ decodeFallback: "Media cannot be decoded here — showing the preview image",
181
185
  },
182
186
  };
183
187
 
@@ -260,7 +264,7 @@ window.__ModuleLoader__.load({
260
264
  wallpaperBlur: clampNum(o.wallpaperBlur, 0, 60, DEFAULTS.wallpaperBlur),
261
265
  rotationEnabled: o.rotationEnabled === true,
262
266
  rotationGroupId: typeof o.rotationGroupId === "string" ? o.rotationGroupId : "",
263
- rotationGroups: readRotationGroups(o.rotationGroups, STRINGS.zh.defaultGroupBase),
267
+ rotationGroups: readRotationGroups(o.rotationGroups, STRINGS[browserLang() || "zh"].defaultGroupBase),
264
268
  rotationSeeded: o.rotationSeeded === true,
265
269
  pauseOnHidden: o.pauseOnHidden !== false,
266
270
  pauseOnBattery: o.pauseOnBattery !== false,
@@ -493,9 +497,14 @@ window.__ModuleLoader__.load({
493
497
  }
494
498
 
495
499
  function startCreateGroup() {
500
+ // Default names must stay unique even after deletions: take the smallest
501
+ // free index instead of count+1.
502
+ const taken = new Set(selection.rotationGroups.map((g) => g.name));
503
+ let n = 1;
504
+ while (taken.has(S().defaultGroupName(n))) n++;
496
505
  selection.editing = {
497
506
  id: nextGroupId(),
498
- name: S().defaultGroupName(selection.rotationGroups.length + 1),
507
+ name: S().defaultGroupName(n),
499
508
  interval: DEFAULTS.rotationInterval,
500
509
  order: "sequence",
501
510
  wallpaperIds: [],
@@ -513,7 +522,7 @@ window.__ModuleLoader__.load({
513
522
  interval: clampNum(draft.interval, 1, 1440, DEFAULTS.rotationInterval),
514
523
  order: draft.order === "random" ? "random" : "sequence",
515
524
  wallpaperIds: Array.isArray(draft.wallpaperIds)
516
- ? draft.wallpaperIds.filter((x) => typeof x === "string" && x)
525
+ ? [...new Set(draft.wallpaperIds.filter((x) => typeof x === "string" && x))]
517
526
  : [],
518
527
  };
519
528
  if (idx >= 0) selection.rotationGroups[idx] = cleaned;
@@ -522,8 +531,13 @@ window.__ModuleLoader__.load({
522
531
  selection.editing = null;
523
532
  if (selection.rotationEnabled && !rotationCandidates().some((w) => w.id === selection.id)) {
524
533
  const first = rotationCandidates()[0];
525
- applySelection(first ? first.id : "");
526
- return;
534
+ if (first) {
535
+ applySelection(first.id);
536
+ return;
537
+ }
538
+ // Saved list has nothing usable — keep the current wallpaper and simply
539
+ // stop rotation instead of clearing the screen.
540
+ selection.rotationEnabled = false;
527
541
  }
528
542
  persistSelection();
529
543
  syncRotationTimer();
@@ -556,7 +570,7 @@ window.__ModuleLoader__.load({
556
570
  function importPlaylistIntoDraft(playlist) {
557
571
  if (!selection.editing || !playlist || !Array.isArray(playlist.wallpaperIds)) return;
558
572
  const usable = new Set(selectableInventory().map((w) => w.id));
559
- selection.editing.wallpaperIds = playlist.wallpaperIds.filter((id) => usable.has(id));
573
+ selection.editing.wallpaperIds = [...new Set(playlist.wallpaperIds.filter((id) => usable.has(id)))];
560
574
  emit();
561
575
  }
562
576
 
@@ -566,10 +580,12 @@ window.__ModuleLoader__.load({
566
580
  const w = selection.id
567
581
  ? selection.inventory.wallpapers.find((x) => x.id === selection.id)
568
582
  : null;
569
- if (isPlayable(w)) {
583
+ if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
570
584
  selection.url = w.media;
571
585
  selection.type = w.type; // "video" | "web"
572
- } else if (w && isStatic(w)) {
586
+ } else if (w && w.preview) {
587
+ // Static path: scene/application wallpapers, and playable wallpapers
588
+ // demoted after repeated media failures (undecodable container…).
573
589
  selection.url = w.preview;
574
590
  selection.type = "image";
575
591
  } else {
@@ -592,11 +608,27 @@ window.__ModuleLoader__.load({
592
608
  return selection.playing && selection.autoPauseReasons.size === 0;
593
609
  }
594
610
 
611
+ // Latest known battery state (null until getBattery resolves). Kept at module
612
+ // scope so re-enabling the toggle can re-evaluate immediately instead of
613
+ // waiting for the next battery event.
614
+ let batteryState = null;
615
+ function applyBatteryPause() {
616
+ if (!batteryState || !selection.pauseOnBattery) return;
617
+ setAutoPause("battery",
618
+ !batteryState.charging && batteryState.level <= BATTERY_SAVER_LEVEL);
619
+ }
620
+
595
621
  // ── Behind-body layer: wallpaper + scrim, with crossfade ────────────────────
596
622
  // Set by a media error handler to force a remount with fresh URLs; cleared
597
623
  // once the remount happens.
598
624
  let forceRemount = false;
599
625
  let lastMediaRecoveryAt = 0;
626
+ // Media failure bookkeeping: the first error for a wallpaper may just be a
627
+ // stale token (fixed by a refresh+remount); a SECOND error for the same id
628
+ // means the bytes themselves are unusable (e.g. an mkv/avi Chromium cannot
629
+ // decode) — demote it to its preview image instead of looping forever.
630
+ const mediaFailures = new Map(); // wallpaperId → consecutive failure count
631
+ const demotedToPreview = new Set(); // wallpaperIds now rendered as stills
600
632
 
601
633
  function buildMedia(sel) {
602
634
  let media;
@@ -617,7 +649,7 @@ window.__ModuleLoader__.load({
617
649
  media.setAttribute("referrerpolicy", "no-referrer");
618
650
  media.setAttribute("frameborder", "0");
619
651
  media.setAttribute("scrolling", "no");
620
- media.setAttribute("title", "Wallpaper Engine web wallpaper");
652
+ media.setAttribute("title", S().iframeTitle);
621
653
  media.className = "webg-media webg-iframe";
622
654
  } else {
623
655
  // Static wallpaper: the scene/application preview as a still image.
@@ -628,22 +660,45 @@ window.__ModuleLoader__.load({
628
660
  media.setAttribute("decoding", "async");
629
661
  media.className = "webg-media";
630
662
  }
631
- // Self-heal: if the media fails to load (e.g. its token died on an
632
- // inventory rebuild), refetch fresh URLs and remount — throttled to once
633
- // per 10 s so a permanently-broken wallpaper cannot loop.
634
663
  media.addEventListener("error", () => {
635
- const now = Date.now();
636
- if (now - lastMediaRecoveryAt < 10000) return;
637
- lastMediaRecoveryAt = now;
638
- forceRemount = true;
639
- loadInventory(true);
664
+ const id = sel.id;
665
+ const count = (mediaFailures.get(id) || 0) + 1;
666
+ mediaFailures.set(id, count);
667
+ const w = selection.inventory.wallpapers.find((x) => x.id === id);
668
+ if (count === 1) {
669
+ // Maybe just a dead token: refetch fresh URLs and remount (throttled).
670
+ const now = Date.now();
671
+ if (now - lastMediaRecoveryAt < 10000) return;
672
+ lastMediaRecoveryAt = now;
673
+ forceRemount = true;
674
+ loadInventory(true);
675
+ } else if (w && w.preview && !demotedToPreview.has(id)) {
676
+ // The media itself is unusable here — fall back to the still preview.
677
+ demotedToPreview.add(id);
678
+ applySelection(id);
679
+ }
640
680
  });
641
681
  return media;
642
682
  }
643
683
 
684
+ // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
685
+ // letting them fire on torn-down nodes afterwards.
686
+ const pendingTimers = new Set();
687
+ function schedule(fn, ms) {
688
+ if (typeof window === "undefined" || typeof window.setTimeout !== "function") {
689
+ fn();
690
+ return null;
691
+ }
692
+ const token = window.setTimeout(() => {
693
+ pendingTimers.delete(token);
694
+ fn();
695
+ }, ms);
696
+ pendingTimers.add(token);
697
+ return token;
698
+ }
699
+
644
700
  function syncLayers() {
645
701
  const existing = document.getElementById(LAYER_ID);
646
- const hasTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function";
647
702
 
648
703
  if (selection.url) {
649
704
  // Key by wallpaper id, NOT by media URL: inventory rebuilds re-mint
@@ -670,8 +725,7 @@ window.__ModuleLoader__.load({
670
725
  if (existing) {
671
726
  existing.classList.add("webg-layer--leave");
672
727
  const stale = existing;
673
- if (hasTimeout) window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
674
- else stale.remove();
728
+ schedule(() => stale.remove(), CROSSFADE_MS + 120);
675
729
  }
676
730
  }
677
731
  const node = document.getElementById(LAYER_ID);
@@ -686,16 +740,14 @@ window.__ModuleLoader__.load({
686
740
  existing.removeAttribute("id");
687
741
  existing.classList.add("webg-layer--leave");
688
742
  const stale = existing;
689
- const finish = () => {
743
+ schedule(() => {
690
744
  stale.remove();
691
745
  if (!document.getElementById(LAYER_ID)) {
692
746
  const s = document.getElementById(SCRIM_ID);
693
747
  if (s) s.remove();
694
748
  document.body.removeAttribute(ACTIVE_ATTR);
695
749
  }
696
- };
697
- if (hasTimeout) window.setTimeout(finish, CROSSFADE_MS + 120);
698
- else finish();
750
+ }, CROSSFADE_MS + 120);
699
751
  }
700
752
 
701
753
  // Scrim: present while a wallpaper is active. On clear it outlives the
@@ -722,8 +774,9 @@ window.__ModuleLoader__.load({
722
774
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
723
775
  s.setProperty("--webg-glass-brightness", "1.04");
724
776
  s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
725
- // Scaling compensates the transparent fringe CSS blur reveals at edges.
726
- s.setProperty("--webg-wallpaper-scale", (1 + selection.wallpaperBlur * 0.006).toFixed(4));
777
+ // Dither-noise strength scales with blur: invisible at 0, ~4% at 60px.
778
+ s.setProperty("--webg-noise-opacity",
779
+ selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
727
780
  // Canvas fit: object-fit/position for video and still wallpapers (iframes
728
781
  // ignore object-fit — web wallpapers always fill their layer).
729
782
  s.setProperty("--webg-fit", selection.fit);
@@ -746,7 +799,7 @@ window.__ModuleLoader__.load({
746
799
  s.removeProperty("--webg-saturate");
747
800
  s.removeProperty("--webg-glass-brightness");
748
801
  s.removeProperty("--webg-wallpaper-blur");
749
- s.removeProperty("--webg-wallpaper-scale");
802
+ s.removeProperty("--webg-noise-opacity");
750
803
  s.removeProperty("--webg-fit");
751
804
  s.removeProperty("--webg-position");
752
805
  const scrim = document.getElementById(SCRIM_ID);
@@ -755,16 +808,28 @@ window.__ModuleLoader__.load({
755
808
 
756
809
  // ── Settings picker ─────────────────────────────────────────────────────────
757
810
  function SliderRow(label, min, max, step, value, onInput, suffix) {
758
- return React.createElement("div", { className: "webg-row webg-slider-row" },
811
+ // A Fragment of three cells — the surrounding .webg-effects grid keeps
812
+ // every slider starting and ending at the same x regardless of how wide
813
+ // the label text is (壁纸模糊 vs 暗化 vs Wallpaper blur…).
814
+ // --webg-p feeds the filled-track gradient; updated inline while dragging
815
+ // so the fill tracks the thumb even between re-renders.
816
+ const pct = ((value - min) / (max - min)) * 100;
817
+ const set = (e) => {
818
+ const v = Number(e.target.value);
819
+ if (e.target.style && typeof e.target.style.setProperty === "function") {
820
+ e.target.style.setProperty("--webg-p", String(((v - min) / (max - min)) * 100));
821
+ }
822
+ onInput(v);
823
+ };
824
+ return React.createElement(React.Fragment, null,
759
825
  React.createElement("span", { className: "webg-hint webg-label" }, label),
760
826
  React.createElement("input", {
761
827
  className: "webg-slider", type: "range",
762
828
  min: String(min), max: String(max), step: String(step),
763
829
  value: String(value),
764
- // onInput fires continuously while dragging (onChange may wait for
765
- // release) — that is what makes the visual feedback instant.
766
- onInput: (e) => onInput(Number(e.target.value)),
767
- onChange: (e) => onInput(Number(e.target.value)),
830
+ style: { "--webg-p": String(pct) },
831
+ onInput: set,
832
+ onChange: set,
768
833
  }),
769
834
  React.createElement("span", { className: "webg-hint webg-value" }, suffix),
770
835
  );
@@ -808,7 +873,13 @@ window.__ModuleLoader__.load({
808
873
  const t = S();
809
874
  const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
810
875
  const onClear = () => applySelection("");
811
- const onRefresh = () => loadInventory(true);
876
+ const onRefresh = () => {
877
+ // A manual refresh also retries wallpapers previously demoted to their
878
+ // preview after decode failures.
879
+ demotedToPreview.clear();
880
+ mediaFailures.clear();
881
+ loadInventory(true);
882
+ };
812
883
  const onLangChange = (e) => {
813
884
  selection.lang = e.target.value;
814
885
  persistSelection();
@@ -828,15 +899,12 @@ window.__ModuleLoader__.load({
828
899
  };
829
900
  const onToggleRotation = () => {
830
901
  selection.rotationEnabled = !selection.rotationEnabled;
831
- if (selection.rotationEnabled) {
832
- if (!selection.rotationGroupId) {
833
- const usable = firstUsableGroup();
834
- if (usable) selection.rotationGroupId = usable.id;
835
- }
836
- if (!rotationCandidates().some((w) => w.id === selection.id)) {
837
- const first = rotationCandidates()[0];
838
- if (first) { applySelection(first.id); return; }
839
- }
902
+ // The checkbox is disabled without a usable group, so a group id always
903
+ // exists here when enabling.
904
+ if (selection.rotationEnabled &&
905
+ !rotationCandidates().some((w) => w.id === selection.id)) {
906
+ const first = rotationCandidates()[0];
907
+ if (first) { applySelection(first.id); return; }
840
908
  }
841
909
  persistSelection();
842
910
  syncRotationTimer();
@@ -860,13 +928,22 @@ window.__ModuleLoader__.load({
860
928
  };
861
929
  const onTogglePauseOnHidden = () => {
862
930
  selection.pauseOnHidden = !selection.pauseOnHidden;
863
- if (!selection.pauseOnHidden) setAutoPause("hidden", false);
931
+ if (selection.pauseOnHidden) {
932
+ // Re-enabling while already hidden must re-apply the reason — the
933
+ // visibilitychange listener only fires on CHANGES.
934
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
935
+ setAutoPause("hidden", true);
936
+ }
937
+ } else {
938
+ setAutoPause("hidden", false);
939
+ }
864
940
  persistSelection();
865
941
  emit();
866
942
  };
867
943
  const onTogglePauseOnBattery = () => {
868
944
  selection.pauseOnBattery = !selection.pauseOnBattery;
869
- if (!selection.pauseOnBattery) setAutoPause("battery", false);
945
+ if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
946
+ else setAutoPause("battery", false);
870
947
  persistSelection();
871
948
  emit();
872
949
  };
@@ -1144,10 +1221,12 @@ window.__ModuleLoader__.load({
1144
1221
  React.createElement("option", { key: POSITIONS[i], value: POSITIONS[i] }, label),
1145
1222
  )),
1146
1223
  ),
1147
- SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1148
- SliderRow(t.scrim, 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
1149
- SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1150
- SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1224
+ React.createElement("div", { className: "webg-effects" },
1225
+ SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1226
+ SliderRow(t.scrim, 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
1227
+ SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1228
+ SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1229
+ ),
1151
1230
  ),
1152
1231
  // ── Resource monitor ──
1153
1232
  sel.id && React.createElement("div", { className: "webg-row webg-monitor-row" },
@@ -1181,6 +1260,7 @@ window.__ModuleLoader__.load({
1181
1260
  : t.statusUsable(usableList.length)) +
1182
1261
  (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1183
1262
  (autoPaused ? " · " + t.autoPaused : "") +
1263
+ (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1184
1264
  (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1185
1265
  ),
1186
1266
  );
@@ -1198,14 +1278,35 @@ window.__ModuleLoader__.load({
1198
1278
  }
1199
1279
  .webg-layer--enter { opacity: 0; }
1200
1280
  .webg-layer--leave { opacity: 0; }
1281
+ /* Overscan: the media is LARGER than the layer by one blur radius on every
1282
+ side, so the transparent fringe CSS blur produces is clipped by the
1283
+ layer's overflow:hidden instead of compensated with a content-warping
1284
+ scale. 1× is the fringe a Gaussian kernel actually needs; more would
1285
+ double GPU fill and mis-anchor non-center object-positions. A slight
1286
+ saturate keeps blurred colours from washing out. */
1201
1287
  .webg-layer .webg-media {
1202
- width: 100%; height: 100%; object-fit: var(--webg-fit, cover);
1288
+ position: absolute;
1289
+ inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1290
+ width: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1291
+ height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1292
+ max-width: none; max-height: none;
1293
+ object-fit: var(--webg-fit, cover);
1203
1294
  object-position: var(--webg-position, center);
1204
1295
  display: block;
1205
1296
  background: transparent; border: 0;
1206
- filter: blur(var(--webg-wallpaper-blur, 0px));
1207
- transform: scale(var(--webg-wallpaper-scale, 1));
1208
- transform-origin: center;
1297
+ filter: blur(var(--webg-wallpaper-blur, 0px)) saturate(1.08);
1298
+ }
1299
+
1300
+ /* Gaussian blur alone shows banding on smooth gradients; a whisper of
1301
+ fractal noise over the layer dithers it away (iOS does the same inside
1302
+ its frosted materials). Opacity rides the blur radius — zero blur, zero
1303
+ noise. */
1304
+ .webg-layer::after {
1305
+ content: ""; position: absolute; inset: 0; pointer-events: none;
1306
+ opacity: var(--webg-noise-opacity, 0);
1307
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/></filter><rect width="120" height="120" filter="url(%23n)"/></svg>');
1308
+ background-size: 120px 120px;
1309
+ mix-blend-mode: overlay;
1209
1310
  }
1210
1311
 
1211
1312
  /* Scrim: above the wallpaper (-1 > -2), below the UI. */
@@ -1272,7 +1373,7 @@ window.__ModuleLoader__.load({
1272
1373
  font-size: 13px;
1273
1374
  color: var(--dsw-alias-state-error-primary, #d44);
1274
1375
  }
1275
- .webg-label { min-width: 28px; }
1376
+ .webg-label { white-space: nowrap; }
1276
1377
  .webg-value { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
1277
1378
 
1278
1379
  .webg-btn {
@@ -1305,13 +1406,33 @@ window.__ModuleLoader__.load({
1305
1406
  }
1306
1407
 
1307
1408
  .webg-select, .webg-text {
1308
- padding: 4px 8px;
1309
- border-radius: 6px;
1409
+ padding: 5px 10px;
1410
+ border-radius: 8px;
1310
1411
  border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
1311
1412
  background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
1312
1413
  color: var(--dsw-alias-label-primary, inherit);
1313
1414
  font-size: 13px;
1415
+ transition: border-color 120ms ease, background-color 120ms ease;
1314
1416
  }
1417
+ /* Custom select: drop the OS chrome, draw our own chevron (mid-gray reads
1418
+ on both light and dark themes), roomier hit area, shell-style focus. */
1419
+ .webg-select {
1420
+ -webkit-appearance: none;
1421
+ appearance: none;
1422
+ padding-right: 28px;
1423
+ cursor: pointer;
1424
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%23888" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>');
1425
+ background-repeat: no-repeat;
1426
+ background-position: right 10px center;
1427
+ }
1428
+ .webg-select:hover:not(:disabled), .webg-text:hover:not(:disabled) {
1429
+ border-color: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.5));
1430
+ }
1431
+ .webg-select:focus-visible, .webg-text:focus-visible {
1432
+ outline: 2px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1433
+ outline-offset: 1px;
1434
+ }
1435
+ .webg-select:disabled { opacity: 0.45; cursor: not-allowed; }
1315
1436
  .webg-text { flex: 1; min-width: 0; }
1316
1437
  .webg-search { max-width: 320px; }
1317
1438
  .webg-lang { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
@@ -1323,8 +1444,43 @@ window.__ModuleLoader__.load({
1323
1444
  font-size: 13px; cursor: pointer;
1324
1445
  }
1325
1446
 
1326
- .webg-slider { flex: 1; accent-color: var(--dsw-alias-brand-primary, rgb(15, 17, 21)); }
1327
- .webg-slider-row { display: flex; align-items: center; gap: 8px; }
1447
+ /* Custom range slider (Chromium): slim filled track + ringed thumb instead
1448
+ of the default chunky control. --webg-p (0–100) drives the fill. */
1449
+ .webg-slider {
1450
+ -webkit-appearance: none;
1451
+ appearance: none;
1452
+ width: 100%; height: 22px; margin: 0;
1453
+ background: transparent; cursor: pointer;
1454
+ }
1455
+ .webg-slider::-webkit-slider-runnable-track {
1456
+ height: 4px; border-radius: 2px;
1457
+ background: linear-gradient(to right,
1458
+ var(--dsw-alias-brand-primary, rgb(15, 17, 21)) 0%,
1459
+ var(--dsw-alias-brand-primary, rgb(15, 17, 21)) calc(var(--webg-p, 0) * 1%),
1460
+ var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)) calc(var(--webg-p, 0) * 1%));
1461
+ }
1462
+ .webg-slider::-webkit-slider-thumb {
1463
+ -webkit-appearance: none;
1464
+ width: 14px; height: 14px; border-radius: 50%;
1465
+ margin-top: -5px; /* centers the 14px thumb on the 4px track */
1466
+ background: var(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
1467
+ border: 2.5px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1468
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
1469
+ transition: transform 120ms ease;
1470
+ }
1471
+ .webg-slider:hover::-webkit-slider-thumb,
1472
+ .webg-slider:focus-visible::-webkit-slider-thumb { transform: scale(1.18); }
1473
+ .webg-slider:focus-visible { outline: none; }
1474
+ /* Effects grid: one shared grid across all slider rows, so every slider
1475
+ starts and ends at the same x no matter how wide its label is. */
1476
+ .webg-effects {
1477
+ display: grid;
1478
+ grid-template-columns: max-content 1fr 3.2em;
1479
+ gap: 8px 10px;
1480
+ align-items: center;
1481
+ }
1482
+ .webg-effects .webg-label { white-space: nowrap; }
1483
+ .webg-effects .webg-value { text-align: right; }
1328
1484
 
1329
1485
  .webg-monitor-row { gap: 14px; }
1330
1486
  .webg-fps { font-variant-numeric: tabular-nums; }
@@ -1460,6 +1616,14 @@ window.__ModuleLoader__.load({
1460
1616
  unsubLayers();
1461
1617
  unsubEffects();
1462
1618
  clearRotationTimer();
1619
+ // Cancel pending crossfade/clear timers so they never fire on
1620
+ // torn-down nodes after dispose.
1621
+ for (const token of [...pendingTimers]) {
1622
+ if (typeof window !== "undefined" && typeof window.clearTimeout === "function") {
1623
+ window.clearTimeout(token);
1624
+ }
1625
+ }
1626
+ pendingTimers.clear();
1463
1627
  // Remove EVERY layer instance (a crossfade-leaving layer has no id).
1464
1628
  const doomed = document.querySelectorAll(".webg-layer");
1465
1629
  for (const node of [...doomed]) node.remove();
@@ -1493,8 +1657,9 @@ window.__ModuleLoader__.load({
1493
1657
  let battery = null;
1494
1658
  let cancelled = false;
1495
1659
  const update = () => {
1496
- if (!battery || !selection.pauseOnBattery) return;
1497
- setAutoPause("battery", !battery.charging && battery.level <= BATTERY_SAVER_LEVEL);
1660
+ if (!battery) return;
1661
+ batteryState = { charging: battery.charging, level: battery.level };
1662
+ applyBatteryPause();
1498
1663
  };
1499
1664
  navigator.getBattery().then((b) => {
1500
1665
  if (cancelled) return;
@@ -1509,6 +1674,7 @@ window.__ModuleLoader__.load({
1509
1674
  battery.removeEventListener("chargingchange", update);
1510
1675
  battery.removeEventListener("levelchange", update);
1511
1676
  }
1677
+ batteryState = null;
1512
1678
  setAutoPause("battery", false);
1513
1679
  };
1514
1680
  });
package/lib/core.js CHANGED
@@ -191,11 +191,13 @@ export function weBinaryName(platform = process.platform) {
191
191
 
192
192
  /**
193
193
  * First candidate directory that contains the WE binary, in order.
194
+ * Async: io methods may be sync or return promises (the host injects
195
+ * fs/promises so discovery never blocks the event loop).
194
196
  * @param {string[]} candidates
195
- * @param {{ existsSync?: (p: string) => boolean, platform?: string }} [io]
196
- * @returns {string | null}
197
+ * @param {{ existsSync?: (p: string) => boolean | Promise<boolean>, platform?: string }} [io]
198
+ * @returns {Promise<string | null>}
197
199
  */
198
- export function findInstallDir(candidates, io = {}) {
200
+ export async function findInstallDir(candidates, io = {}) {
199
201
  const exists = io.existsSync || existsSync;
200
202
  const bin = weBinaryName(io.platform || process.platform);
201
203
  const seen = new Set();
@@ -204,7 +206,7 @@ export function findInstallDir(candidates, io = {}) {
204
206
  const dir = normalize(String(raw));
205
207
  if (seen.has(dir)) continue;
206
208
  seen.add(dir);
207
- if (exists(join(dir, bin))) return dir;
209
+ if (await exists(join(dir, bin))) return dir;
208
210
  }
209
211
  return null;
210
212
  }
@@ -237,15 +239,15 @@ export function installDirCandidates(input = {}) {
237
239
  * libraryfolders.vdf, so it must be recovered by direct inspection).
238
240
  * @param {string[]} probes
239
241
  * @param {string[]} vdfLibraries
240
- * @param {{ existsSync?: (p: string) => boolean, platform?: string }} [io]
241
- * @returns {string[]}
242
+ * @param {{ existsSync?: (p: string) => boolean | Promise<boolean>, platform?: string }} [io]
243
+ * @returns {Promise<string[]>}
242
244
  */
243
- export function workshopLibraryRoots(probes, vdfLibraries, io = {}) {
245
+ export async function workshopLibraryRoots(probes, vdfLibraries, io = {}) {
244
246
  const exists = io.existsSync || existsSync;
245
247
  const bin = weBinaryName(io.platform || process.platform);
246
248
  const libs = [...vdfLibraries];
247
249
  for (const probe of probes) {
248
- if (exists(join(probe, 'steamapps', 'common', 'wallpaper_engine', bin))) {
250
+ if (await exists(join(probe, 'steamapps', 'common', 'wallpaper_engine', bin))) {
249
251
  libs.push(probe);
250
252
  }
251
253
  }
@@ -298,18 +300,20 @@ export function isInsideDir(root, target, platform = process.platform) {
298
300
  * server at arbitrary files.
299
301
  *
300
302
  * @param {string} dir
301
- * @param {{ readFileSync?: (p: string, e: string) => string, existsSync?: (p: string) => boolean }} [io]
303
+ * @param {{ readFileSync?: (p: string, e: string) => string | Promise<string>, existsSync?: (p: string) => boolean | Promise<boolean> }} [io]
302
304
  */
303
- export function readProject(dir, io = {}) {
305
+ export async function readProject(dir, io = {}) {
304
306
  const read = io.readFileSync || readFileSync;
305
307
  const exists = io.existsSync || existsSync;
306
308
  const pj = join(dir, 'project.json');
307
- if (!exists(pj)) return null;
309
+ if (!await exists(pj)) return null;
308
310
  try {
309
- const o = JSON.parse(read(pj, 'utf8'));
311
+ const o = JSON.parse(await read(pj, 'utf8'));
310
312
  if (!o || typeof o !== 'object' || typeof o.file !== 'string' || !o.file.trim()) return null;
311
- let type = typeof o.type === 'string' ? o.type.toLowerCase() : inferType(o.file);
312
- if (!KINDS.includes(type)) type = 'scene';
313
+ // A declared-but-unknown type (typo, trailing whitespace) is not
314
+ // evidence of "scene": fall back to extension inference first.
315
+ let type = typeof o.type === 'string' ? o.type.trim().toLowerCase() : '';
316
+ if (!KINDS.includes(type)) type = inferType(o.file);
313
317
  const fileAbs = resolve(dir, o.file);
314
318
  if (!isInsideDir(dir, fileAbs)) return null;
315
319
  let previewAbs = null;
@@ -334,19 +338,19 @@ export function readProject(dir, io = {}) {
334
338
  * Enumerate every wallpaper project under the given roots (first occurrence
335
339
  * of an id wins), sorted case-insensitively by title.
336
340
  * @param {string[]} roots
337
- * @param {{ readdirSync?: (p: string) => string[], statSync?: (p: string) => { isDirectory(): boolean } }} [io]
341
+ * @param {{ readdirSync?: (p: string) => string[] | Promise<string[]>, statSync?: (p: string) => { isDirectory(): boolean } | Promise<{ isDirectory(): boolean }> }} [io]
338
342
  */
339
- export function enumerateWallpapers(roots, io = {}) {
343
+ export async function enumerateWallpapers(roots, io = {}) {
340
344
  const readdir = io.readdirSync || readdirSync;
341
345
  const stat = io.statSync || statSync;
342
346
  const found = new Map();
343
347
  for (const root of roots) {
344
348
  let entries;
345
- try { entries = readdir(root); } catch { continue; }
349
+ try { entries = await readdir(root); } catch { continue; }
346
350
  for (const entry of entries) {
347
351
  const dir = join(root, entry);
348
- try { if (!stat(dir).isDirectory()) continue; } catch { continue; }
349
- const proj = readProject(dir, io);
352
+ try { if (!(await stat(dir)).isDirectory()) continue; } catch { continue; }
353
+ const proj = await readProject(dir, io);
350
354
  if (!proj || found.has(proj.id)) continue;
351
355
  found.set(proj.id, proj);
352
356
  }
@@ -360,21 +364,21 @@ export function enumerateWallpapers(roots, io = {}) {
360
364
  * plus the workshop content folder of every owning library.
361
365
  * @param {string | null} installDir
362
366
  * @param {string[]} libraryRoots
363
- * @param {{ existsSync?: (p: string) => boolean }} [io]
364
- * @returns {string[]}
367
+ * @param {{ existsSync?: (p: string) => boolean | Promise<boolean> }} [io]
368
+ * @returns {Promise<string[]>}
365
369
  */
366
- export function wallpaperRoots(installDir, libraryRoots, io = {}) {
370
+ export async function wallpaperRoots(installDir, libraryRoots, io = {}) {
367
371
  const exists = io.existsSync || existsSync;
368
372
  const roots = [];
369
373
  if (installDir) {
370
374
  for (const sub of ['defaultprojects', 'myprojects']) {
371
375
  const p = join(installDir, 'projects', sub);
372
- if (exists(p)) roots.push(p);
376
+ if (await exists(p)) roots.push(p);
373
377
  }
374
378
  }
375
379
  for (const lib of libraryRoots) {
376
380
  const ws = join(lib, 'steamapps', 'workshop', 'content', WE_APPID);
377
- if (exists(ws)) roots.push(ws);
381
+ if (await exists(ws)) roots.push(ws);
378
382
  }
379
383
  return roots;
380
384
  }
@@ -463,30 +467,36 @@ export function resolvePlaylistItem(item, byPath, byId) {
463
467
  /**
464
468
  * Parse an HTTP Range header for a resource of `size` bytes.
465
469
  * Supports `bytes=a-b`, `bytes=a-`, `bytes=-suffix`.
470
+ *
471
+ * RFC 9110 §14.2: a server MUST ignore a Range header it does not understand
472
+ * (malformed, wrong unit, multi-range) and answer 200 — 416 is reserved for
473
+ * syntactically valid but unsatisfiable ranges.
474
+ *
466
475
  * @returns {null | { start: number, end: number } | { error: true }}
467
- * null → no/empty header, serve the whole body (200)
468
- * error → unsatisfiable or malformed, answer 416
476
+ * null → no header, or an invalid/unsupported one: ignore it, serve 200
477
+ * error → valid syntax but unsatisfiable for this resource: answer 416
469
478
  * start/end → inclusive byte range, answer 206
470
479
  */
471
480
  export function parseRangeHeader(header, size) {
472
481
  if (!header || typeof header !== 'string') return null;
473
482
  if (!Number.isFinite(size) || size < 0) return { error: true };
474
483
  const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
475
- if (!m) return { error: true };
484
+ if (!m) return null; // wrong unit / multi-range / garbage → ignore (200)
476
485
  const [, a, b] = m;
477
- if (a === '' && b === '') return { error: true };
486
+ if (a === '' && b === '') return null; // "bytes=-" is not a range → 200
478
487
  let start;
479
488
  let end;
480
489
  if (a === '') {
481
490
  const suffix = parseInt(b, 10);
482
- if (!Number.isFinite(suffix) || suffix <= 0) return { error: true };
491
+ if (!Number.isFinite(suffix) || suffix <= 0) return null; // "bytes=-0"
483
492
  start = Math.max(0, size - suffix);
484
493
  end = size - 1;
485
494
  } else {
486
495
  start = parseInt(a, 10);
487
496
  end = b === '' ? size - 1 : Math.min(parseInt(b, 10), size - 1);
488
497
  }
489
- if (!Number.isFinite(start) || !Number.isFinite(end)) return { error: true };
498
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
499
+ // Valid syntax; unsatisfiable for this resource.
490
500
  if (size === 0 || start > end || start >= size) return { error: true };
491
501
  return { start, end };
492
502
  }
@@ -517,13 +527,16 @@ export function mimeFor(absPath) {
517
527
  *
518
528
  * @param {{ installDir: string | null, libraryRoots: string[] }} discovery
519
529
  * @param {object} [io] overridable IO: existsSync/readFileSync/readdirSync/
520
- * statSync (default to node:fs), plus:
521
- * - `mint(entry)` — mint a media/preview token for a file entry; the host
522
- * provides a random-token map, tests provide a spy.
530
+ * statSync (default to node:fs sync functions; each may also return a
531
+ * promise — the host injects fs/promises to keep the event loop free),
532
+ * plus:
533
+ * - `mint(entry)` — mint a media/preview token for a file entry; when it
534
+ * returns a falsy value (or is omitted) the URL is null, never
535
+ * "…/media/null".
523
536
  * - `base` — URL prefix for media/preview links (default '/we-background').
524
- * @returns {{ installDir, total, portableCount, wallpapers, playlists }}
537
+ * @returns {Promise<{ installDir, total, portableCount, wallpapers, playlists }>}
525
538
  */
526
- export function buildInventoryFrom(discovery = {}, io = {}) {
539
+ export async function buildInventoryFrom(discovery = {}, io = {}) {
527
540
  const exists = io.existsSync || existsSync;
528
541
  const read = io.readFileSync || readFileSync;
529
542
  const mint = io.mint || (() => null);
@@ -531,35 +544,38 @@ export function buildInventoryFrom(discovery = {}, io = {}) {
531
544
 
532
545
  const installDir = discovery.installDir || null;
533
546
  const libraryRoots = Array.isArray(discovery.libraryRoots) ? discovery.libraryRoots : [];
534
- const roots = wallpaperRoots(installDir, libraryRoots, { existsSync: exists });
535
- const projects = enumerateWallpapers(roots, io);
547
+ const roots = await wallpaperRoots(installDir, libraryRoots, { existsSync: exists });
548
+ const projects = await enumerateWallpapers(roots, io);
536
549
  const byId = new Map(projects.map((p) => [p.id, p]));
537
550
  const byPath = new Map(projects.map((p) => [pathKey(p.fileAbs), p.id]));
538
551
 
539
- const wallpapers = projects.map((p) => {
552
+ const wallpapers = [];
553
+ for (const p of projects) {
540
554
  const portable = PORTABLE_KINDS.includes(p.type);
541
- const hasMedia = portable && exists(p.fileAbs);
542
- const hasPreview = Boolean(p.previewAbs) && exists(p.previewAbs);
543
- return {
555
+ const hasMedia = portable && await exists(p.fileAbs);
556
+ const hasPreview = Boolean(p.previewAbs) && await exists(p.previewAbs);
557
+ const mediaToken = hasMedia
558
+ ? mint({ abs: p.fileAbs, rootDir: p.type === 'web' ? p.dir : null, key: `${p.id}:media` })
559
+ : null;
560
+ const previewToken = hasPreview
561
+ ? mint({ abs: p.previewAbs, rootDir: null, key: `${p.id}:preview` })
562
+ : null;
563
+ wallpapers.push({
544
564
  id: p.id,
545
565
  title: p.title,
546
566
  type: p.type,
547
- playable: hasMedia,
548
- media: hasMedia
549
- ? `${base}/media/${mint({ abs: p.fileAbs, rootDir: p.type === 'web' ? p.dir : null })}`
550
- : null,
551
- preview: hasPreview
552
- ? `${base}/preview/${mint({ abs: p.previewAbs, rootDir: null })}`
553
- : null,
554
- };
555
- });
567
+ playable: hasMedia && Boolean(mediaToken),
568
+ media: hasMedia && mediaToken ? `${base}/media/${mediaToken}` : null,
569
+ preview: hasPreview && previewToken ? `${base}/preview/${previewToken}` : null,
570
+ });
571
+ }
556
572
  const playableIds = new Set(wallpapers.filter((w) => w.playable).map((w) => w.id));
557
573
 
558
574
  let playlists = [];
559
575
  const configPath = installDir ? join(installDir, 'config.json') : null;
560
- if (configPath && exists(configPath)) {
576
+ if (configPath && await exists(configPath)) {
561
577
  try {
562
- playlists = parsePlaylists(JSON.parse(read(configPath, 'utf8')));
578
+ playlists = parsePlaylists(JSON.parse(await read(configPath, 'utf8')));
563
579
  } catch { /* malformed config.json → no playlists, not fatal */ }
564
580
  }
565
581
  const playlistView = playlists.map((playlist, index) => {
package/lib/index.js CHANGED
@@ -6,17 +6,21 @@
6
6
  * All parsing/enumeration logic lives in the pure layer (lib/core.js); this
7
7
  * file only wires it into same-origin HTTP routes on `ctx.webServer`:
8
8
  *
9
- * GET /we-background/inventory[?refresh=1] → { installDir, wallpapers, playlists }
9
+ * GET /we-background/inventory[?refresh=1] → { installDir, total,
10
+ * portableCount, wallpapers, playlists }
10
11
  * GET /we-background/media/<token>[/<sub-asset>…] → video / web HTML + assets
11
12
  * GET /we-background/preview/<token> → preview image
12
13
  *
13
14
  * Security model
14
- * - Tokens are 72-bit random values minted per inventory build (not
15
- * reversible encodings of paths). Knowing a filesystem path grants
16
- * nothing; only files enumerated into the inventory are reachable.
17
- * Rebuilding the inventory re-mints every token, so stale URLs die.
15
+ * - Tokens are 72-bit random values (not reversible encodings of paths).
16
+ * Knowing a filesystem path grants nothing; only files enumerated into
17
+ * the inventory are reachable. Tokens are stable per wallpaper across
18
+ * TTL-triggered rebuilds (so an in-flight video stream is never killed
19
+ * by a background refresh) and re-minted on an explicit ?refresh=1, so
20
+ * deliberately stale URLs die.
18
21
  * - Web-wallpaper sub-assets resolve strictly INSIDE their project
19
- * directory (core.isInsideDir) — `..` traversal is answered 403.
22
+ * directory (core.isInsideDir) — `..` and backslash traversal are
23
+ * answered 403.
20
24
  * - project.json-declared files must also be inside the project directory
21
25
  * (core.readProject), so a hostile workshop item cannot point the server
22
26
  * at arbitrary files.
@@ -26,6 +30,9 @@
26
30
  * - Everything is loopback same-origin; `X-Content-Type-Options: nosniff`
27
31
  * on every media response.
28
32
  *
33
+ * Async: discovery (reg.exe, vdf scan) and inventory builds run through
34
+ * fs/promises, so a rebuild never blocks the event loop mid-stream.
35
+ *
29
36
  * Lifecycle: every route registers through the plugin fiber and unwinds on
30
37
  * unload; the inventory cache and token map are dropped with it. The plugin
31
38
  * contributes no model-visible tool and no prompt text.
@@ -33,11 +40,9 @@
33
40
 
34
41
  import {
35
42
  createReadStream,
36
- existsSync,
37
- readFileSync,
38
- statSync,
43
+ promises as fsp,
39
44
  } from 'node:fs';
40
- import { execFileSync } from 'node:child_process';
45
+ import { execFile } from 'node:child_process';
41
46
  import { randomBytes } from 'node:crypto';
42
47
  import { join } from 'node:path';
43
48
 
@@ -47,133 +52,197 @@ import * as core from './core.js';
47
52
  const BASE = '/we-background';
48
53
  /** Inventory is cached and rebuilt at most this often unless ?refresh=1. */
49
54
  const INVENTORY_TTL_MS = 30_000;
55
+ /** Steam layout barely moves; re-discover at most this often on TTL builds. */
56
+ const DISCOVERY_TTL_MS = 10 * 60_000;
57
+
58
+ /** fs/promises-backed io for the pure layer (awaited there). */
59
+ const asyncIo = {
60
+ existsSync: async (p) => { try { await fsp.access(p); return true; } catch { return false; } },
61
+ readFileSync: (p, encoding) => fsp.readFile(p, encoding),
62
+ readdirSync: (p) => fsp.readdir(p),
63
+ statSync: (p) => fsp.stat(p),
64
+ };
50
65
 
51
66
  /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
52
67
  function steamRegistryRoot() {
53
- if (process.platform !== 'win32') return null;
54
- try {
68
+ if (process.platform !== 'win32') return Promise.resolve(null);
69
+ return new Promise((resolvePromise) => {
55
70
  const reg = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe');
56
- const out = execFileSync(
71
+ execFile(
57
72
  reg,
58
73
  ['query', 'HKCU\\Software\\Valve\\Steam', '/v', 'SteamPath'],
59
- { encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
74
+ { encoding: 'utf8', windowsHide: true, timeout: 5000 },
75
+ (err, stdout) => {
76
+ if (err) { resolvePromise(null); return; }
77
+ resolvePromise(core.steamPathFromRegQuery(stdout));
78
+ },
60
79
  );
61
- return core.steamPathFromRegQuery(out);
62
- } catch {
63
- return null;
64
- }
80
+ });
65
81
  }
66
82
 
67
83
  /** Full discovery pass: install dir + every library owning WE content. */
68
- function discover() {
84
+ async function discover() {
69
85
  const probes = core.steamProbeDirs();
70
- const registryRoot = steamRegistryRoot();
86
+ const registryRoot = await steamRegistryRoot();
71
87
  const searchRoots = registryRoot ? [registryRoot, ...probes] : probes;
72
88
  const vdfLibraries = [];
73
89
  for (const root of searchRoots) {
74
90
  const vdf = join(root, 'steamapps', 'libraryfolders.vdf');
75
- if (!existsSync(vdf)) continue;
91
+ if (!await asyncIo.existsSync(vdf)) continue;
76
92
  try {
77
- vdfLibraries.push(...core.librariesFromVdfText(readFileSync(vdf, 'utf8')));
93
+ vdfLibraries.push(...core.librariesFromVdfText(await asyncIo.readFileSync(vdf, 'utf8')));
78
94
  } catch { /* unreadable vdf → skip this root */ }
79
95
  }
80
- const installDir = core.findInstallDir(
96
+ const installDir = await core.findInstallDir(
81
97
  core.installDirCandidates({ registryRoot, libraryRoots: vdfLibraries }),
98
+ asyncIo,
82
99
  );
83
- const libraryRoots = core.workshopLibraryRoots(searchRoots, vdfLibraries);
100
+ const libraryRoots = await core.workshopLibraryRoots(searchRoots, vdfLibraries, asyncIo);
84
101
  return { installDir, libraryRoots };
85
102
  }
86
103
 
87
- export const inject = ['webServer'];
88
-
89
- export function apply(ctx) {
90
- const webServer = ctx.webServer;
91
- if (!webServer || typeof webServer.register !== 'function') {
92
- return () => {}; // defensive: inject guarantees the service in practice
93
- }
104
+ /**
105
+ * Register all routes on a webServer-like registry and return a disposer.
106
+ * Extracted from apply() so integration tests can drive the HTTP surface
107
+ * against a fixture discovery without a live Steam install.
108
+ *
109
+ * @param {{ register(route: { kind: 'exact'|'prefix', path: string, handler: Function }): () => void }} webServer
110
+ * @param {{ discover?: () => Promise<{ installDir: string|null, libraryRoots: string[] }>, inventoryTtlMs?: number, discoveryTtlMs?: number }} [opts]
111
+ */
112
+ export function createRouteRegistrar(webServer, opts = {}) {
113
+ const discoverFn = opts.discover || discover;
114
+ const inventoryTtlMs = opts.inventoryTtlMs ?? INVENTORY_TTL_MS;
115
+ const discoveryTtlMs = opts.discoveryTtlMs ?? DISCOVERY_TTL_MS;
94
116
 
95
117
  // ── Token registry ──────────────────────────────────────────────────────
96
- // token → { abs, rootDir|null }. rootDir is set for renderable projects so
97
- // web wallpapers can fetch their bundled sub-assets (contained, see below).
118
+ // token → { abs, rootDir|null }. rootDir is set for renderable web projects
119
+ // so their bundled sub-assets can be fetched (contained, see below).
120
+ // Tokens are keyed per wallpaper asset (`<id>:media` / `<id>:preview`):
121
+ // TTL rebuilds REUSE the token for an unchanged asset (in-flight streams
122
+ // survive), prune assets that vanished, and only an explicit ?refresh=1
123
+ // re-mints everything.
98
124
  const tokens = new Map();
125
+ const tokenByKey = new Map();
99
126
  const mint = (entry) => {
127
+ const key = entry && entry.key;
128
+ if (key && tokenByKey.has(key)) return tokenByKey.get(key);
100
129
  const token = randomBytes(9).toString('base64url'); // 72 bits, url-safe
101
130
  tokens.set(token, entry);
131
+ if (key) tokenByKey.set(key, token);
102
132
  return token;
103
133
  };
134
+ const remintAll = () => { tokens.clear(); tokenByKey.clear(); };
135
+ const pruneTokens = (payload) => {
136
+ const live = new Set();
137
+ for (const w of payload.wallpapers) {
138
+ for (const url of [w.media, w.preview]) {
139
+ if (!url) continue;
140
+ const token = url.slice(url.lastIndexOf('/') + 1);
141
+ live.add(token);
142
+ }
143
+ }
144
+ for (const token of [...tokens.keys()]) {
145
+ if (!live.has(token)) {
146
+ const entry = tokens.get(token);
147
+ tokens.delete(token);
148
+ if (entry && entry.key && tokenByKey.get(entry.key) === token) {
149
+ tokenByKey.delete(entry.key);
150
+ }
151
+ }
152
+ }
153
+ };
104
154
 
105
155
  // ── Inventory (cached) ──────────────────────────────────────────────────
106
156
  let cache = null; // { at: number, payload: object }
157
+ let discoveryCache = null; // { at: number, value: object }
158
+ let building = null; // in-flight build promise (request coalescing)
107
159
 
108
- // Discovery is expensive (reg.exe + vdf scan) and the install layout barely
109
- // changes — resolve it ONCE and reuse across builds; only an explicit
110
- // refresh (or TTL expiry on the first build) forces a re-discovery.
111
- let discoveryCache = null;
112
- function getDiscovery(force) {
113
- if (!force && discoveryCache) return discoveryCache;
114
- discoveryCache = discover();
115
- return discoveryCache;
160
+ async function getDiscovery(force) {
161
+ const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
162
+ if (!force && fresh) return discoveryCache.value;
163
+ const value = await discoverFn();
164
+ discoveryCache = { at: Date.now(), value };
165
+ return value;
116
166
  }
117
167
 
118
- function buildInventory(force) {
119
- tokens.clear(); // re-mint: tokens from older builds stop resolving
120
- return core.buildInventoryFrom(getDiscovery(force), { mint });
168
+ async function buildInventory(force) {
169
+ if (force) remintAll(); // explicit refresh: stale URLs die on purpose
170
+ const payload = await core.buildInventoryFrom(await getDiscovery(force), {
171
+ ...asyncIo,
172
+ mint,
173
+ base: BASE,
174
+ });
175
+ pruneTokens(payload);
176
+ return payload;
121
177
  }
122
178
 
123
179
  function getInventory(force) {
124
- if (!force && cache && Date.now() - cache.at < INVENTORY_TTL_MS) return cache.payload;
125
- const payload = buildInventory(force);
126
- cache = { at: Date.now(), payload };
127
- return payload;
180
+ if (!force && cache && Date.now() - cache.at < inventoryTtlMs) {
181
+ return Promise.resolve(cache.payload);
182
+ }
183
+ // Coalesce concurrent builds (double-clicked 刷新, parallel clients).
184
+ if (building) return building;
185
+ building = buildInventory(force)
186
+ .then((payload) => { cache = { at: Date.now(), payload }; return payload; })
187
+ .finally(() => { building = null; });
188
+ return building;
128
189
  }
129
190
 
130
191
  // ── Response helpers ────────────────────────────────────────────────────
131
- function sendJson(res, status, body) {
192
+ function sendJson(req, res, status, body) {
193
+ const text = JSON.stringify(body);
132
194
  res.statusCode = status;
133
195
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
134
196
  res.setHeader('Cache-Control', 'no-store');
135
- res.end(JSON.stringify(body));
197
+ res.setHeader('Content-Length', String(Buffer.byteLength(text)));
198
+ res.end(req.method === 'HEAD' ? undefined : text);
136
199
  }
137
200
 
138
201
  /** Stream one file with Range support (video seeking) and HEAD handling. */
139
202
  function serveFile(absPath, req, res) {
140
- let st;
141
- try { st = statSync(absPath); } catch { st = null; }
142
- if (!st || !st.isFile()) {
143
- res.statusCode = 404;
144
- res.end('not found');
145
- return;
146
- }
147
- res.setHeader('Content-Type', core.mimeFor(absPath));
148
- res.setHeader('Accept-Ranges', 'bytes');
149
- res.setHeader('Cache-Control', 'no-store');
150
- res.setHeader('X-Content-Type-Options', 'nosniff');
203
+ fsp.stat(absPath).then((st) => {
204
+ if (!st.isFile()) {
205
+ res.statusCode = 404;
206
+ res.setHeader('Content-Length', '0');
207
+ res.end();
208
+ return;
209
+ }
210
+ res.setHeader('Content-Type', core.mimeFor(absPath));
211
+ res.setHeader('Accept-Ranges', 'bytes');
212
+ res.setHeader('Cache-Control', 'no-store');
213
+ res.setHeader('X-Content-Type-Options', 'nosniff');
151
214
 
152
- const range = core.parseRangeHeader(req.headers.range, st.size);
153
- if (range && range.error) {
154
- res.statusCode = 416;
155
- res.setHeader('Content-Range', `bytes */${st.size}`);
156
- res.end();
157
- return;
158
- }
159
- const head = req.method === 'HEAD';
160
- if (range) {
161
- res.statusCode = 206;
162
- res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${st.size}`);
163
- res.setHeader('Content-Length', String(range.end - range.start + 1));
164
- } else {
165
- res.setHeader('Content-Length', String(st.size));
166
- }
167
- if (head) { res.end(); return; }
215
+ const range = core.parseRangeHeader(req.headers.range, st.size);
216
+ if (range && range.error) {
217
+ res.statusCode = 416;
218
+ res.setHeader('Content-Range', `bytes */${st.size}`);
219
+ res.setHeader('Content-Length', '0');
220
+ res.end();
221
+ return;
222
+ }
223
+ const head = req.method === 'HEAD';
224
+ if (range) {
225
+ res.statusCode = 206;
226
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${st.size}`);
227
+ res.setHeader('Content-Length', String(range.end - range.start + 1));
228
+ } else {
229
+ res.setHeader('Content-Length', String(st.size));
230
+ }
231
+ if (head) { res.end(); return; }
168
232
 
169
- const stream = range
170
- ? createReadStream(absPath, { start: range.start, end: range.end })
171
- : createReadStream(absPath);
172
- stream.on('error', () => {
173
- if (!res.headersSent) res.statusCode = 500;
233
+ const stream = range
234
+ ? createReadStream(absPath, { start: range.start, end: range.end })
235
+ : createReadStream(absPath);
236
+ // The file can vanish between stat and open; ending normally would
237
+ // leave the already-announced Content-Length unsatisfied and the
238
+ // client hanging — destroy the response instead.
239
+ stream.on('error', () => { res.destroy(); });
240
+ stream.pipe(res);
241
+ }).catch(() => {
242
+ res.statusCode = 404;
243
+ res.setHeader('Content-Length', '0');
174
244
  res.end();
175
245
  });
176
- stream.pipe(res);
177
246
  }
178
247
 
179
248
  // ── Routes ──────────────────────────────────────────────────────────────
@@ -184,20 +253,15 @@ export function apply(ctx) {
184
253
  path: `${BASE}/inventory`,
185
254
  handler: (req, res) => {
186
255
  if (req.method !== 'GET' && req.method !== 'HEAD') {
187
- sendJson(res, 405, { error: 'method not allowed' });
256
+ sendJson(req, res, 405, { error: 'method not allowed' });
188
257
  return;
189
258
  }
190
- try {
191
- const url = new URL(req.url || '/', 'http://x');
192
- const payload = JSON.stringify(getInventory(url.searchParams.get('refresh') === '1'));
193
- res.statusCode = 200;
194
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
195
- res.setHeader('Cache-Control', 'no-store');
196
- res.setHeader('Content-Length', String(Buffer.byteLength(payload)));
197
- res.end(req.method === 'HEAD' ? undefined : payload);
198
- } catch (err) {
199
- sendJson(res, 500, { error: String(err && err.message ? err.message : err) });
200
- }
259
+ const url = new URL(req.url || '/', 'http://x');
260
+ getInventory(url.searchParams.get('refresh') === '1')
261
+ .then((payload) => sendJson(req, res, 200, payload))
262
+ .catch((err) => {
263
+ sendJson(req, res, 500, { error: String(err && err.message ? err.message : err) });
264
+ });
201
265
  },
202
266
  }));
203
267
 
@@ -208,14 +272,14 @@ export function apply(ctx) {
208
272
  path: `${BASE}/${seg}`,
209
273
  handler: (req, res) => {
210
274
  if (req.method !== 'GET' && req.method !== 'HEAD') {
211
- sendJson(res, 405, { error: 'method not allowed' });
275
+ sendJson(req, res, 405, { error: 'method not allowed' });
212
276
  return;
213
277
  }
214
278
  let pathname;
215
279
  try {
216
280
  pathname = new URL(req.url || '/', 'http://x').pathname;
217
281
  } catch {
218
- sendJson(res, 400, { error: 'bad request' });
282
+ sendJson(req, res, 400, { error: 'bad request' });
219
283
  return;
220
284
  }
221
285
  const rest = pathname.slice(prefix.length); // "<token>" | "<token>/<rel…>"
@@ -223,14 +287,14 @@ export function apply(ctx) {
223
287
  const rawToken = slash < 0 ? rest : rest.slice(0, slash);
224
288
  let token;
225
289
  try { token = decodeURIComponent(rawToken); } catch {
226
- sendJson(res, 400, { error: 'bad token' });
290
+ sendJson(req, res, 400, { error: 'bad token' });
227
291
  return;
228
292
  }
229
293
  const entry = tokens.get(token);
230
294
  if (!entry) {
231
295
  res.statusCode = 404;
232
- if (req.method === 'HEAD') res.end();
233
- else res.end('not found');
296
+ res.setHeader('Content-Length', '0');
297
+ res.end();
234
298
  return;
235
299
  }
236
300
  let abs = entry.abs;
@@ -238,22 +302,25 @@ export function apply(ctx) {
238
302
  // Sub-asset of a bundled web wallpaper: must stay inside its dir.
239
303
  if (seg !== 'media' || !entry.rootDir) {
240
304
  res.statusCode = 404;
241
- if (req.method === 'HEAD') res.end();
242
- else res.end('not found');
305
+ res.setHeader('Content-Length', '0');
306
+ res.end();
243
307
  return;
244
308
  }
245
309
  let rel;
246
310
  try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
247
- sendJson(res, 400, { error: 'bad path' });
311
+ sendJson(req, res, 400, { error: 'bad path' });
248
312
  return;
249
313
  }
250
- if (!rel || rel.split('/').some((part) => part === '..' || part === '')) {
251
- sendJson(res, 403, { error: 'forbidden' });
314
+ // Backslash parts are never legitimate in a URL path here; on win32
315
+ // `..\evil` would slip past a `/`-only split (the isInsideDir
316
+ // backstop still catches it — this is defense in depth).
317
+ if (!rel || rel.split('/').some((part) => part === '..' || part === '' || part.includes('\\'))) {
318
+ sendJson(req, res, 403, { error: 'forbidden' });
252
319
  return;
253
320
  }
254
321
  abs = join(entry.rootDir, ...rel.split('/'));
255
322
  if (!core.isInsideDir(entry.rootDir, abs)) {
256
- sendJson(res, 403, { error: 'forbidden' });
323
+ sendJson(req, res, 403, { error: 'forbidden' });
257
324
  return;
258
325
  }
259
326
  }
@@ -262,17 +329,24 @@ export function apply(ctx) {
262
329
  }));
263
330
  }
264
331
 
265
- // Warm the discovery cache now (reg.exe + vdf scan) so the first inventory
266
- // request answers immediately instead of paying the discovery cost inline.
267
- try { getDiscovery(false); } catch { /* non-fatal: inventory will 500 with reason */ }
332
+ // Warm the discovery cache now (async, off the request path) so the first
333
+ // inventory request answers quickly.
334
+ getDiscovery(false).catch(() => { /* non-fatal: inventory will 500 with reason */ });
268
335
 
269
336
  return () => {
270
337
  for (const d of disposers) { try { d(); } catch { /* ignore */ } }
271
338
  disposers.length = 0;
272
- tokens.clear();
339
+ remintAll();
273
340
  cache = null;
274
341
  discoveryCache = null;
275
342
  };
276
343
  }
277
344
 
345
+ export const inject = ['webServer'];
346
+
347
+ export function apply(ctx) {
348
+ // inject guarantees the service is present before apply runs.
349
+ return createRouteRegistrar(ctx.webServer);
350
+ }
351
+
278
352
  export default { inject, apply };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "0.1.4",
4
- "description": "Wallpaper Engine Video/Web wallpapers as the DSH web GUI background — discovery, sandboxed rendering, rotation lists, liquid-glass tuning.",
3
+ "version": "0.1.6",
4
+ "description": "Wallpaper Engine library as the DSH web GUI background — video/web/still wallpapers, rotation lists, search, resource monitor, bilingual UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -9,10 +9,14 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/Weilv-D/wallpaper-engine-dsh.git"
11
11
  },
12
+ "bugs": "https://github.com/Weilv-D/wallpaper-engine-dsh/issues",
13
+ "homepage": "https://github.com/Weilv-D/wallpaper-engine-dsh#readme",
12
14
  "keywords": [
13
15
  "dsh",
14
16
  "dsh-plugin",
17
+ "deepseek",
15
18
  "deepseek-harness",
19
+ "cordis",
16
20
  "wallpaper",
17
21
  "wallpaper-engine",
18
22
  "background",
@@ -59,7 +63,7 @@
59
63
  "scripts": {
60
64
  "build": "node scripts/build-client.mjs",
61
65
  "verify": "node scripts/verify-client.mjs",
62
- "test": "node --test test/core.test.mjs",
66
+ "test": "node --test test/core.test.mjs test/host.test.mjs",
63
67
  "prepare": "node scripts/prepare.mjs",
64
68
  "prepublishOnly": "npm run test && npm run build && npm run verify",
65
69
  "release": "npm version patch -m \"chore: release v%s\" && git push --follow-tags",