wallpaper-engine-dsh 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh.md)
4
4
 
5
- A DSH bundle that turns your local **Wallpaper Engine** library into the live background of the DSH web GUI (`dsh web`). Video wallpapers play behind the chat, web wallpapers render in place, and scene/application wallpapers join in as still images — with crossfade transitions, liquid-glass panels, rotation lists, search, a resource monitor, and a bilingual UI.
5
+ A DSH bundle that turns your local **Wallpaper Engine** library into the live background of the DSH web GUI (`dsh web`). Video wallpapers play behind the chat and web wallpapers render in place — with crossfade transitions, liquid-glass panels, rotation lists, search, a resource monitor, and a bilingual UI.
6
6
 
7
7
  ## Features
8
8
 
9
- - **Whole-library browsing** — video and web wallpapers render live; scene and application wallpapers show their preview as a static background. Everything with a preview or playable file is selectable, badged by type (Video / Web / Still).
9
+ - **Renderable-only browsing** — video and web wallpapers render live and are the only kinds listed: scene and application wallpapers are Wallpaper Engine's private `.pkg` scene packages, which no browser can render, so they are filtered out instead of offered as degraded stills. A video the browser cannot decode falls back to its preview image until you refresh.
10
10
  - **Search** — filter the grid instantly by title or workshop id.
11
11
  - **Rotation lists** — any number of carousel lists, each with its own wallpapers, interval (1–120 min) and order (sequence/random). Your first playable Wallpaper Engine playlist is imported automatically; others can be imported into any list.
12
12
  - **Four live sliders** — Wallpaper blur, Scrim, Borders, Glass — all instant and persisted.
@@ -50,13 +50,13 @@ The bundle has two halves:
50
50
  - `GET /we-background/inventory[?refresh=1]`
51
51
  - `GET /we-background/media/<token>[/asset…]` — with HTTP Range support for video seeking; web wallpapers can fetch their bundled sub-assets
52
52
  - `GET /we-background/preview/<token>`
53
- - 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. Scene and application wallpapers can only show their workshop preview — the bundle reads each preview's pixel size and presents low-resolution ones as sharp centered art over a blurred copy of themselves, instead of a mushy fullscreen stretch.
53
+ - 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 that repeatedly fails to decode (an unsupported container) falls back to its preview still, and the next Refresh retries the live media.
54
54
 
55
55
  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.
56
56
 
57
57
  ## Limitations
58
58
 
59
- - Scene and application wallpapers render as still images; their live animation remains Wallpaper Engine's desktop job.
59
+ - Scene and application wallpapers are not listed at all: they are Wallpaper Engine's private `.pkg` scene packages, and their live animation can only be rendered by Wallpaper Engine itself on the desktop.
60
60
  - Web wallpapers run isolated from the page's storage, so ones that persist state across loads won't keep it.
61
61
  - The glass effect reads DSH design tokens; if a future shell redesign renames them, frosting degrades to plain transparency.
62
62
 
package/README.zh.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh.md)
4
4
 
5
- 一个 DSH bundle:把本机 **Wallpaper Engine** 壁纸库变成 DSH web GUI(`dsh web`)的动态背景。视频壁纸在聊天界面背后播放,网页壁纸原地渲染,场景/应用壁纸以预览图作为静态背景——带交叉淡入、液态玻璃面板、轮播列表、搜索和资源监控。
5
+ 一个 DSH bundle:把本机 **Wallpaper Engine** 壁纸库变成 DSH web GUI(`dsh web`)的动态背景。视频壁纸在聊天界面背后播放,网页壁纸原地渲染——带交叉淡入、液态玻璃面板、轮播列表、搜索和资源监控。
6
6
 
7
7
  ## 功能
8
8
 
9
- - **全库浏览** —— 视频与网页壁纸动态渲染;场景与应用壁纸显示预览图作为静态背景。凡是有预览图或可播放文件的壁纸都可选用,按类型标注角标(视频 / 网页 / 静态)。
9
+ - **只列可原生渲染的壁纸** —— 视频与网页壁纸动态渲染,且是列表中仅有的两类:场景/应用壁纸是 Wallpaper Engine 私有的 `.pkg` 场景包,浏览器无法渲染,因此直接从列表中过滤,而不是以降级的静态图充数。浏览器解不了的视频会临时退到预览图,下次刷新即重试原媒体。
10
10
  - **搜索** —— 按标题或创意工坊 ID 即时筛选网格。
11
11
  - **轮播列表** —— 可建任意多个列表,各自拥有壁纸集合、切换间隔(1–120 分钟)和顺序(顺序/随机)。首次运行自动导入第一个可播放的 Wallpaper Engine 播放列表,其余列表可随时导入。
12
12
  - **四个实时滑杆** —— 壁纸模糊、暗化、边框、玻璃,即时生效并持久保存。
@@ -50,13 +50,13 @@ bundle 分为两半:
50
50
  - `GET /we-background/inventory[?refresh=1]`
51
51
  - `GET /we-background/media/<token>[/资源…]` —— 支持 HTTP Range,视频可拖动进度;网页壁纸可加载其打包的子资源
52
52
  - `GET /we-background/preview/<token>`
53
- - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。场景与应用壁纸在浏览器里只能显示工坊预览图——插件会读取每张预览图的像素尺寸,低分辨率的以「清晰原图居中 + 自身高斯模糊铺底」的氛围方式呈现,而不是拉伸成一片糊。
53
+ - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;反复解码失败的视频(不支持的容器)退到预览图,下次刷新重试原媒体。
54
54
 
55
55
  清单由宿主缓存 30 秒,媒体令牌按壁纸稳定复用(后台重建不会掐断播放中的流);安装发现异步执行并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
56
56
 
57
57
  ## 限制
58
58
 
59
- - 场景与应用壁纸以静态图呈现,其动态渲染仍是 Wallpaper Engine 桌面的职责。
59
+ - 场景与应用壁纸完全不出现在列表里:它们是 Wallpaper Engine 私有的 `.pkg` 场景包,动态渲染只能由桌面端的 Wallpaper Engine 完成。
60
60
  - 网页壁纸与页面存储隔离,需要跨加载保存状态的壁纸无法保留状态。
61
61
  - 玻璃效果读 DSH 设计令牌;若未来外壳重构重命名令牌,磨砂会退化为普通半透明。
62
62
 
package/lib/client.js CHANGED
@@ -19,12 +19,6 @@ window.__ModuleLoader__.load({
19
19
  /** Below this FPS the picker shows a performance hint. */
20
20
  const LOW_FPS = 24;
21
21
 
22
- /**
23
- * Below this preview width a fullscreen cover render is pure upscale mush —
24
- * present as ambient (sharp contain over a blurred copy of itself) instead.
25
- */
26
- const LOW_RES_PREVIEW_PX = 1200;
27
-
28
22
  // Respect the OS "reduce motion" preference: start paused rather than
29
23
  // autoplaying a video loop at someone who asked for less motion.
30
24
  const REDUCED_MOTION = typeof window !== "undefined" &&
@@ -54,7 +48,6 @@ window.__ModuleLoader__.load({
54
48
  noPreview: "无预览",
55
49
  badgeVideo: "视频",
56
50
  badgeWeb: "网页",
57
- badgeStill: "静态",
58
51
  rotationList: "轮播列表",
59
52
  selectList: "— 选择轮播列表 —",
60
53
  noLists: "— 暂无轮播列表 —",
@@ -100,8 +93,6 @@ window.__ModuleLoader__.load({
100
93
  adjustDone: "完成",
101
94
  adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
102
95
  resetCrop: "重置裁剪",
103
- ambientNote: "预览分辨率低,已氛围化显示",
104
- lowResBadge: "低清",
105
96
  statusGroup: (name, total, usable, interval, order) =>
106
97
  "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
107
98
  statusUsable: (n) => n + " 张可用壁纸",
@@ -134,7 +125,6 @@ window.__ModuleLoader__.load({
134
125
  noPreview: "No preview",
135
126
  badgeVideo: "Video",
136
127
  badgeWeb: "Web",
137
- badgeStill: "Still",
138
128
  rotationList: "Rotation list",
139
129
  selectList: "— Select a list —",
140
130
  noLists: "— No lists yet —",
@@ -180,8 +170,6 @@ window.__ModuleLoader__.load({
180
170
  adjustDone: "Done",
181
171
  adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
182
172
  resetCrop: "Reset crop",
183
- ambientNote: "Low-res preview — ambient fill",
184
- lowResBadge: "Low-res",
185
173
  statusGroup: (name, total, usable, interval, order) =>
186
174
  "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
187
175
  statusUsable: (n) => n + " usable wallpapers",
@@ -297,7 +285,6 @@ window.__ModuleLoader__.load({
297
285
  ...readPersisted(),
298
286
  url: null,
299
287
  type: null,
300
- previewW: null, // pixel width of the still being shown (ambient decisions)
301
288
  adjusting: false, // crop-adjust overlay open (not persisted)
302
289
  playing: !REDUCED_MOTION,
303
290
  loading: false,
@@ -348,17 +335,16 @@ window.__ModuleLoader__.load({
348
335
 
349
336
  // ── Wallpaper predicates ─────────────────────────────────────────────────────
350
337
  // playable: real media (video/web) renders live.
351
- // static: scene/application with a preview image renders as a still.
338
+ // Renderable kinds ONLY — scene/application wallpapers are .pkg scene
339
+ // packages the browser cannot render (their live animation is Wallpaper
340
+ // Engine's desktop job), so they are filtered out of the grid entirely
341
+ // rather than offered as degraded preview stills.
352
342
  function isPlayable(w) {
353
343
  return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
354
344
  }
355
345
 
356
- function isStatic(w) {
357
- return Boolean(w && !isPlayable(w) && typeof w.preview === "string" && w.preview);
358
- }
359
-
360
346
  function isSelectable(w) {
361
- return isPlayable(w) || isStatic(w);
347
+ return isPlayable(w);
362
348
  }
363
349
 
364
350
  function selectableInventory() {
@@ -366,13 +352,22 @@ window.__ModuleLoader__.load({
366
352
  }
367
353
 
368
354
  // ── Inventory ────────────────────────────────────────────────────────────────
355
+ // Monotonic token for in-flight inventory fetches: a fetch that started
356
+ // before a newer one must not commit its (older) state afterwards, and a
357
+ // fetch still in flight at dispose must not commit anything at all — its
358
+ // applySelection call would re-arm the rotation timer on a torn-down store.
359
+ let inventoryEpoch = 0;
360
+ let disposed = false;
361
+
369
362
  async function loadInventory(forceRefresh) {
363
+ const epoch = ++inventoryEpoch;
370
364
  selection.loading = true;
371
365
  emit();
372
366
  try {
373
367
  const res = await fetch(forceRefresh ? INVENTORY_URL + "?refresh=1" : INVENTORY_URL, { cache: "no-store" });
374
368
  if (!res.ok) throw new Error("inventory HTTP " + res.status);
375
369
  const data = await res.json();
370
+ if (disposed || epoch !== inventoryEpoch) return;
376
371
  selection.inventory = {
377
372
  installDir: data.installDir || null,
378
373
  wallpapers: Array.isArray(data.wallpapers) ? data.wallpapers : [],
@@ -382,11 +377,13 @@ window.__ModuleLoader__.load({
382
377
  error: null,
383
378
  };
384
379
  } catch (err) {
380
+ if (disposed || epoch !== inventoryEpoch) return;
385
381
  selection.inventory = {
386
382
  installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [],
387
383
  error: String(err && err.message ? err.message : err),
388
384
  };
389
385
  }
386
+ if (disposed || epoch !== inventoryEpoch) return;
390
387
  selection.loading = false;
391
388
  selection.loaded = true;
392
389
 
@@ -604,17 +601,14 @@ window.__ModuleLoader__.load({
604
601
  if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
605
602
  selection.url = w.media;
606
603
  selection.type = w.type; // "video" | "web"
607
- selection.previewW = null;
608
- } else if (w && w.preview) {
609
- // Static path: scene/application wallpapers, and playable wallpapers
610
- // demoted after repeated media failures (undecodable container…).
604
+ } else if (isPlayable(w) && w.preview) {
605
+ // Playable wallpaper demoted after repeated media failures (an
606
+ // undecodable container) — its preview still is the graceful fallback.
611
607
  selection.url = w.preview;
612
608
  selection.type = "image";
613
- selection.previewW = typeof w.previewW === "number" ? w.previewW : null;
614
609
  } else {
615
610
  selection.url = null;
616
611
  selection.type = null;
617
- selection.previewW = null;
618
612
  }
619
613
  syncRotationTimer();
620
614
  emit();
@@ -646,11 +640,10 @@ window.__ModuleLoader__.load({
646
640
  // Set by a media error handler to force a remount with fresh URLs; cleared
647
641
  // once the remount happens.
648
642
  let forceRemount = false;
649
- let lastMediaRecoveryAt = 0;
650
- // Media failure bookkeeping: the first error for a wallpaper may just be a
651
- // stale token (fixed by a refresh+remount); a SECOND error for the same id
652
- // means the bytes themselves are unusable (e.g. an mkv/avi Chromium cannot
653
- // decode) — demote it to its preview image instead of looping forever.
643
+ // Per-wallpaper recovery timestamps: the first error for a wallpaper may
644
+ // just be a stale token; a SECOND error for the same id means the bytes are
645
+ // unusable — demote it to its preview instead of looping forever.
646
+ const lastRecoveryAt = new Map(); // wallpaperId → last recovery timestamp
654
647
  const mediaFailures = new Map(); // wallpaperId → consecutive failure count
655
648
  const demotedToPreview = new Set(); // wallpaperIds now rendered as stills
656
649
 
@@ -676,33 +669,14 @@ window.__ModuleLoader__.load({
676
669
  media.setAttribute("title", S().iframeTitle);
677
670
  media.className = "webg-media webg-iframe";
678
671
  } else {
679
- // Static wallpaper: the scene/application preview as a still image.
672
+ // Static fallback: a playable wallpaper demoted after repeated media
673
+ // failures — its preview still beats a black screen.
680
674
  media = document.createElement("img");
681
675
  media.src = sel.url;
682
676
  media.alt = "";
683
677
  media.draggable = false;
684
678
  media.setAttribute("decoding", "async");
685
679
  media.className = "webg-media";
686
- // Low-resolution previews (many workshop scenes ship 256–500px thumbs)
687
- // look terrible stretched fullscreen under cover. Present them like
688
- // music-app artwork instead: the image at its natural sharpness
689
- // (contain), over a heavily blurred full-bleed copy of itself.
690
- if (sel.fit === "cover" && typeof sel.previewW === "number" &&
691
- sel.previewW > 0 && sel.previewW < LOW_RES_PREVIEW_PX) {
692
- const wrap = document.createElement("div");
693
- wrap.className = "webg-ambient";
694
- const bg = document.createElement("img");
695
- bg.src = sel.url;
696
- bg.alt = "";
697
- bg.draggable = false;
698
- bg.className = "webg-ambient-bg";
699
- wrap.appendChild(bg);
700
- media.classList.add("webg-ambient-fg");
701
- wrap.appendChild(media);
702
- attachStillSampling(media);
703
- attachMediaError(media, sel);
704
- return wrap;
705
- }
706
680
  }
707
681
  attachStillSampling(media, sel);
708
682
  attachMediaError(media, sel);
@@ -725,16 +699,23 @@ window.__ModuleLoader__.load({
725
699
  mediaFailures.set(id, count);
726
700
  const w = selection.inventory.wallpapers.find((x) => x.id === id);
727
701
  if (count === 1) {
728
- // Maybe just a dead token: refetch fresh URLs and remount (throttled).
702
+ // Maybe just a dead token: refetch fresh URLs and remount. Throttled
703
+ // PER WALLPAPER — a global window would strand a video whose first
704
+ // error lands soon after an unrelated recovery (no remount, no second
705
+ // error, blank layer until a manual refresh).
729
706
  const now = Date.now();
730
- if (now - lastMediaRecoveryAt < 10000) return;
731
- lastMediaRecoveryAt = now;
707
+ if (now - (lastRecoveryAt.get(id) || 0) < 10000) return;
708
+ lastRecoveryAt.set(id, now);
732
709
  forceRemount = true;
733
710
  loadInventory(true);
734
711
  } else if (w && w.preview && !demotedToPreview.has(id)) {
735
712
  // The media itself is unusable here — fall back to the still preview.
736
713
  demotedToPreview.add(id);
737
714
  applySelection(id);
715
+ } else if (!w || (!w.preview && !w.media)) {
716
+ // Nothing renderable at all (preview-less or vanished from the
717
+ // inventory): clear the selection instead of a black screen loop.
718
+ applySelection("");
738
719
  }
739
720
  });
740
721
  }
@@ -794,8 +775,11 @@ window.__ModuleLoader__.load({
794
775
  };
795
776
  const onPointerMove = (e) => {
796
777
  if (!drag) return;
797
- const w = overlay.clientWidth || 1;
798
- const h = overlay.clientHeight || 1;
778
+ // translate % refers to the MEDIA BOX, which (blur overscan) is wider
779
+ // than the viewport by 2× the wallpaper blur — divide by that, so the
780
+ // content tracks the cursor 1:1 at any blur setting.
781
+ const w = (overlay.clientWidth || 1) + 2 * selection.wallpaperBlur;
782
+ const h = (overlay.clientHeight || 1) + 2 * selection.wallpaperBlur;
799
783
  // True clamping (clampNum is a validate-or-fallback for persisted data).
800
784
  selection.offsetX = Math.min(200, Math.max(-200,
801
785
  selection.offsetX + ((e.clientX - drag.x) / w) * 100));
@@ -809,10 +793,20 @@ window.__ModuleLoader__.load({
809
793
  drag = null;
810
794
  persistSelection();
811
795
  };
796
+ // pointercancel (touch gesture takeover): end the drag without losing the
797
+ // current offsets — otherwise drag stays "stuck" until the next pointerup.
798
+ const onPointerCancel = () => {
799
+ if (!drag) return;
800
+ drag = null;
801
+ persistSelection();
802
+ };
812
803
  const onWheel = (e) => {
813
804
  if (typeof e.preventDefault === "function") e.preventDefault();
814
- const next = clampNum(selection.zoom * Math.exp(-(e.deltaY || 0) * 0.0012),
815
- 0.25, 4, selection.zoom);
805
+ // True clamping to [0.25, 4] — clampNum is a validate-or-fallback for
806
+ // persisted data and would REJECT an out-of-range zoom instead of
807
+ // pinning it to the boundary.
808
+ const next = Math.min(4, Math.max(0.25,
809
+ selection.zoom * Math.exp(-(e.deltaY || 0) * 0.0012)));
816
810
  selection.zoom = Math.round(next * 100) / 100;
817
811
  applyEffects();
818
812
  persistSelection();
@@ -823,6 +817,7 @@ window.__ModuleLoader__.load({
823
817
  overlay.addEventListener("pointerdown", onPointerDown);
824
818
  overlay.addEventListener("pointermove", onPointerMove);
825
819
  overlay.addEventListener("pointerup", onPointerUp);
820
+ overlay.addEventListener("pointercancel", onPointerCancel);
826
821
  overlay.addEventListener("wheel", onWheel, { passive: false });
827
822
  done.addEventListener("click", onDone);
828
823
  document.addEventListener("keydown", onKey);
@@ -831,6 +826,7 @@ window.__ModuleLoader__.load({
831
826
  overlay.removeEventListener("pointerdown", onPointerDown);
832
827
  overlay.removeEventListener("pointermove", onPointerMove);
833
828
  overlay.removeEventListener("pointerup", onPointerUp);
829
+ overlay.removeEventListener("pointercancel", onPointerCancel);
834
830
  overlay.removeEventListener("wheel", onWheel);
835
831
  done.removeEventListener("click", onDone);
836
832
  document.removeEventListener("keydown", onKey);
@@ -847,12 +843,6 @@ window.__ModuleLoader__.load({
847
843
  emit();
848
844
  }
849
845
 
850
- function isAmbientActive(sel) {
851
- return sel.type === "image" && sel.fit === "cover" &&
852
- typeof sel.previewW === "number" && sel.previewW > 0 &&
853
- sel.previewW < LOW_RES_PREVIEW_PX;
854
- }
855
-
856
846
  // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
857
847
  // letting them fire on torn-down nodes afterwards.
858
848
  const pendingTimers = new Set();
@@ -958,6 +948,7 @@ window.__ModuleLoader__.load({
958
948
  const g = sampleCanvas.getContext("2d", { willReadFrequently: true });
959
949
  g.drawImage(media, 0, 0, 16, 16);
960
950
  const d = g.getImageData(0, 0, 16, 16).data;
951
+ if (!d || d.length < 4) return; // unreadable frame → keep previous sample
961
952
  let sum = 0;
962
953
  for (let i = 0; i < d.length; i += 4) {
963
954
  sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
@@ -1059,7 +1050,9 @@ window.__ModuleLoader__.load({
1059
1050
  min: String(min), max: String(max), step: String(step),
1060
1051
  value: String(value),
1061
1052
  style: { "--webg-p": String(pct) },
1062
- onInput: set,
1053
+ // React normalizes onChange on range inputs to the DOM input event —
1054
+ // it fires CONTINUOUSLY while dragging. Registering onInput too would
1055
+ // run the handler twice per tick.
1063
1056
  onChange: set,
1064
1057
  }),
1065
1058
  React.createElement("span", { className: "webg-hint webg-value" }, suffix),
@@ -1068,8 +1061,7 @@ window.__ModuleLoader__.load({
1068
1061
 
1069
1062
  function typeBadge(w, t) {
1070
1063
  if (w.type === "video") return t.badgeVideo;
1071
- if (w.type === "web") return t.badgeWeb;
1072
- return t.badgeStill;
1064
+ return t.badgeWeb;
1073
1065
  }
1074
1066
 
1075
1067
  function matchesQuery(w, q) {
@@ -1093,8 +1085,6 @@ window.__ModuleLoader__.load({
1093
1085
  })
1094
1086
  : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1095
1087
  React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
1096
- (typeof w.previewW === "number" && w.previewW > 0 && w.previewW < LOW_RES_PREVIEW_PX) &&
1097
- React.createElement("span", { className: "webg-card-lowres" }, t.lowResBadge),
1098
1088
  React.createElement("span", { className: "webg-card-title" }, w.title),
1099
1089
  );
1100
1090
  }
@@ -1111,6 +1101,7 @@ window.__ModuleLoader__.load({
1111
1101
  // preview after decode failures.
1112
1102
  demotedToPreview.clear();
1113
1103
  mediaFailures.clear();
1104
+ lastRecoveryAt.clear();
1114
1105
  loadInventory(true);
1115
1106
  };
1116
1107
  const onLangChange = (e) => {
@@ -1326,7 +1317,10 @@ window.__ModuleLoader__.load({
1326
1317
  value: String(editing.interval),
1327
1318
  onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1328
1319
  },
1329
- ...INTERVALS.map((minutes) =>
1320
+ // A persisted interval outside the preset steps (imported groups,
1321
+ // hand-edited storage) still gets a matching option — otherwise the
1322
+ // select would silently display the first preset instead of reality.
1323
+ ...[...new Set([...INTERVALS, editing.interval])].sort((x, y) => x - y).map((minutes) =>
1330
1324
  React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1331
1325
  )),
1332
1326
  React.createElement("span", { className: "webg-hint webg-label" }, t.order),
@@ -1424,9 +1418,10 @@ window.__ModuleLoader__.load({
1424
1418
  disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1425
1419
  title: t.intervalTitle,
1426
1420
  },
1427
- ...INTERVALS.map((minutes) =>
1428
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1429
- )),
1421
+ ...[...new Set([...INTERVALS, group ? group.interval : DEFAULTS.rotationInterval])]
1422
+ .sort((x, y) => x - y).map((minutes) =>
1423
+ React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1424
+ )),
1430
1425
  !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1431
1426
  sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1432
1427
  ),
@@ -1453,12 +1448,11 @@ window.__ModuleLoader__.load({
1453
1448
  React.createElement("button", {
1454
1449
  className: "webg-btn", type: "button", onClick: resetCrop,
1455
1450
  }, t.resetCrop),
1456
- isAmbientActive(sel) &&
1457
- React.createElement("span", { className: "webg-hint" }, t.ambientNote),
1458
1451
  ),
1459
1452
  React.createElement("div", { className: "webg-effects" },
1460
1453
  SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1461
- SliderRow(t.scrim, 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
1454
+ SliderRow(t.scrim, 0, 90, 5, Math.min(90, Math.round(sel.scrim * 100)), onScrim,
1455
+ Math.round(sel.scrim * 100) + "%"),
1462
1456
  SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1463
1457
  SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1464
1458
  ),
@@ -1542,26 +1536,6 @@ window.__ModuleLoader__.load({
1542
1536
  transform-origin: center center;
1543
1537
  }
1544
1538
 
1545
- /* Ambient presentation for low-resolution stills: the sharp image (fg,
1546
- contain) floats over a heavily blurred full-bleed copy of itself (bg).
1547
- The fg keeps the user's wallpaper-blur/crop; the bg has its own fixed
1548
- blur with its own overscan, so its fringe never shows. */
1549
- .webg-ambient {
1550
- position: absolute;
1551
- inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1552
- overflow: hidden;
1553
- }
1554
- .webg-ambient-bg {
1555
- position: absolute; inset: -64px;
1556
- width: calc(100% + 128px); height: calc(100% + 128px);
1557
- object-fit: cover; display: block;
1558
- filter: blur(48px) saturate(1.25) brightness(0.92);
1559
- }
1560
- .webg-ambient .webg-media {
1561
- inset: 0; width: 100%; height: 100%;
1562
- object-fit: contain;
1563
- }
1564
-
1565
1539
  /* Crop-adjust overlay: topmost, transparent — only its hint bar is chrome.
1566
1540
  The live layer is moved INSIDE, so editing is what-you-see. */
1567
1541
  .webg-adjust {
@@ -1804,11 +1778,6 @@ window.__ModuleLoader__.load({
1804
1778
  font-size: 10px; line-height: 1.5;
1805
1779
  color: #fff; background: rgba(0, 0, 0, 0.5);
1806
1780
  }
1807
- .webg-card-lowres {
1808
- position: absolute; top: 4px; left: 4px; padding: 1px 6px; border-radius: 4px;
1809
- font-size: 10px; line-height: 1.5;
1810
- color: #fff; background: rgba(0, 0, 0, 0.5);
1811
- }
1812
1781
  .webg-card-title {
1813
1782
  position: absolute; left: 0; right: 0; bottom: 0; padding: 3px 6px;
1814
1783
  font-size: 11px; line-height: 1.2; color: #fff;
@@ -1919,6 +1888,9 @@ window.__ModuleLoader__.load({
1919
1888
  observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1920
1889
  }
1921
1890
  return () => {
1891
+ // Stop any in-flight inventory fetch from committing state (and
1892
+ // re-arming the rotation timer) after teardown began.
1893
+ disposed = true;
1922
1894
  unsubLayers();
1923
1895
  unsubEffects();
1924
1896
  // Close the crop-adjust overlay if it is open (moves the layer back,
package/lib/core.js CHANGED
@@ -25,59 +25,6 @@ export const WE_APPID = '431960';
25
25
  /** Wallpaper kinds WE can declare. */
26
26
  export const KINDS = Object.freeze(['scene', 'video', 'web', 'application']);
27
27
 
28
- // ── 6. Image dimension sniffing ─────────────────────────────────────────────
29
-
30
- /**
31
- * Read pixel dimensions from an image's header bytes (JPEG/PNG/GIF/WebP).
32
- * Used to tell the client how sharp a scene/application preview really is —
33
- * workshop previews range from 256px thumbnails to full renders, and the UI
34
- * presents low-resolution ones differently.
35
- *
36
- * Header-only: reads at most 64 KiB, never decodes pixels. Returns
37
- * `{ w, h }` or null for unrecognized/truncated data.
38
- *
39
- * @param {Buffer|Uint8Array} b
40
- * @returns {{ w: number, h: number } | null}
41
- */
42
- export function sniffImageSize(b) {
43
- if (!b || b.length < 10) return null; // GIF needs 10; other formats check their own reach
44
- // PNG: 8-byte signature, then IHDR with width/height big-endian at 16/20.
45
- if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) {
46
- if (b.length < 24) return null;
47
- return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
48
- }
49
- // GIF: logical screen dimensions little-endian at 6/8.
50
- const sig6 = b.toString('latin1', 0, 6);
51
- if (sig6 === 'GIF87a' || sig6 === 'GIF89a') {
52
- return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
53
- }
54
- // JPEG: walk markers to the first start-of-frame segment.
55
- if (b[0] === 0xFF && b[1] === 0xD8) {
56
- let i = 2;
57
- while (i + 9 < b.length) {
58
- if (b[i] !== 0xFF) { i++; continue; }
59
- const marker = b[i + 1];
60
- if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
61
- return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
62
- }
63
- i += 2 + b.readUInt16BE(i + 2);
64
- }
65
- return null;
66
- }
67
- // WebP: RIFF container; VP8X carries canvas size minus one, uint24 LE.
68
- if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP' &&
69
- b.toString('latin1', 12, 16) === 'VP8X' && b.length >= 30) {
70
- return {
71
- w: 1 + b[24] + (b[25] << 8) + (b[26] << 16),
72
- h: 1 + b[27] + (b[28] << 8) + (b[29] << 16),
73
- };
74
- }
75
- return null;
76
- }
77
-
78
- /** Kinds whose only browser-renderable asset is the preview image. */
79
- const PREVIEW_ONLY_KINDS = new Set(['scene', 'application']);
80
-
81
28
  /** Kinds this bundle can render in a browser. */
82
29
  export const PORTABLE_KINDS = Object.freeze(['video', 'web']);
83
30
 
@@ -101,6 +48,15 @@ export function parseKeyValues(text) {
101
48
  const src = String(text);
102
49
  const n = src.length;
103
50
  let i = 0;
51
+ let depth = 0;
52
+ const MAX_DEPTH = 100; // pathological nesting degrades, never overflows
53
+
54
+ // `__proto__`/`constructor`/`prototype` keys would corrupt (or fabricate
55
+ // via) the prototype chain through plain assignment — parse and discard.
56
+ const POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
57
+ const safeAssign = (obj, key, val) => {
58
+ if (!POISON_KEYS.has(key)) obj[key] = val;
59
+ };
104
60
 
105
61
  const skipSpace = () => {
106
62
  for (;;) {
@@ -136,6 +92,18 @@ export function parseKeyValues(text) {
136
92
  const value = () => {
137
93
  skipSpace();
138
94
  if (src[i] !== '{') return token();
95
+ if (++depth > MAX_DEPTH) {
96
+ // Too deep: consume the block without building it, keep parsing.
97
+ let d = 1;
98
+ i++;
99
+ while (i < n && d > 0) {
100
+ if (src[i] === '{') d++;
101
+ else if (src[i] === '}') d--;
102
+ i++;
103
+ }
104
+ depth--;
105
+ return null;
106
+ }
139
107
  i++;
140
108
  const obj = {};
141
109
  for (;;) {
@@ -143,8 +111,9 @@ export function parseKeyValues(text) {
143
111
  if (i >= n) break;
144
112
  if (src[i] === '}') { i++; break; }
145
113
  const key = token();
146
- obj[key] = value();
114
+ safeAssign(obj, key, value());
147
115
  }
116
+ depth--;
148
117
  return obj;
149
118
  };
150
119
 
@@ -154,7 +123,7 @@ export function parseKeyValues(text) {
154
123
  if (i >= n) break;
155
124
  const key = token();
156
125
  if (!key) { i++; continue; }
157
- root[key] = value();
126
+ safeAssign(root, key, value());
158
127
  }
159
128
  return root;
160
129
  }
@@ -499,7 +468,8 @@ export function pathKey(file) {
499
468
  * to an enumerated wallpaper id. Strategy order:
500
469
  * 1. exact normalized path match against enumerated main files,
501
470
  * 2. workshop url fragment `…/431960/<projectId>/…`,
502
- * 3. trailing project-folder name (covers install-relative entries).
471
+ * 3. trailing project-folder name (covers install-relative entries),
472
+ * 4. bare workshop id.
503
473
  * @param {string} item
504
474
  * @param {Map<string, string>} byPath pathKey(fileAbs) → id
505
475
  * @param {Map<string, unknown>} byId id → project
@@ -512,6 +482,7 @@ export function resolvePlaylistItem(item, byPath, byId) {
512
482
  if (workshop && byId.has(workshop[1])) return workshop[1];
513
483
  const folder = /[\\/]([^\\/]+)[\\/][^\\/]+$/i.exec(item);
514
484
  if (folder && byId.has(folder[1])) return folder[1];
485
+ if (typeof item === 'string' && byId.has(item)) return item; // bare id
515
486
  return null;
516
487
  }
517
488
 
@@ -604,8 +575,11 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
604
575
 
605
576
  const wallpapers = [];
606
577
  for (const p of projects) {
607
- const portable = PORTABLE_KINDS.includes(p.type);
608
- const hasMedia = portable && await exists(p.fileAbs);
578
+ // Renderable kinds ONLY: scene/application wallpapers are .pkg scene
579
+ // packages no browser can render, and their preview stills are a
580
+ // degraded experience — skip them instead of offering them.
581
+ if (!PORTABLE_KINDS.includes(p.type)) continue;
582
+ const hasMedia = await exists(p.fileAbs);
609
583
  const hasPreview = Boolean(p.previewAbs) && await exists(p.previewAbs);
610
584
  const mediaToken = hasMedia
611
585
  ? mint({ abs: p.fileAbs, rootDir: p.type === 'web' ? p.dir : null, key: `${p.id}:media` })
@@ -613,24 +587,15 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
613
587
  const previewToken = hasPreview
614
588
  ? mint({ abs: p.previewAbs, rootDir: null, key: `${p.id}:preview` })
615
589
  : null;
616
- // Preview pixel size matters for scene/application wallpapers: the
617
- // preview is ALL the browser can show, so the client needs to know how
618
- // sharp it is. Sniff only for preview-only kinds (videos don't care).
619
- let previewSize = null;
620
- if (hasPreview && PREVIEW_ONLY_KINDS.has(p.type) &&
621
- typeof io.readBuffer === 'function') {
622
- try { previewSize = sniffImageSize(await io.readBuffer(p.previewAbs, 65536)); }
623
- catch { previewSize = null; }
624
- }
625
590
  wallpapers.push({
626
591
  id: p.id,
627
592
  title: p.title,
628
593
  type: p.type,
629
594
  playable: hasMedia && Boolean(mediaToken),
630
595
  media: hasMedia && mediaToken ? `${base}/media/${mediaToken}` : null,
596
+ // The preview stays in the payload even though stills are never
597
+ // selected: it is the decode-failure fallback for playable entries.
631
598
  preview: hasPreview && previewToken ? `${base}/preview/${previewToken}` : null,
632
- previewW: previewSize ? previewSize.w : null,
633
- previewH: previewSize ? previewSize.h : null,
634
599
  });
635
600
  }
636
601
  const playableIds = new Set(wallpapers.filter((w) => w.playable).map((w) => w.id));
@@ -647,7 +612,11 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
647
612
  const seen = new Set();
648
613
  for (const item of playlist.items) {
649
614
  const id = resolvePlaylistItem(item, byPath, byId);
650
- if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
615
+ // Only ids present IN THE PAYLOAD: a scene/application item resolves
616
+ // to an enumerated project but is filtered from wallpapers[] — keeping
617
+ // it would hand the client a dangling id it cannot render or look up.
618
+ // Such items count as unresolved (they are, for our purposes).
619
+ if (id && playableIds.has(id) && !seen.has(id)) { seen.add(id); ids.push(id); }
651
620
  }
652
621
  return {
653
622
  id: `pl-${index}`,
package/lib/index.js CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  } from 'node:fs';
45
45
  import { execFile } from 'node:child_process';
46
46
  import { randomBytes } from 'node:crypto';
47
- import { join } from 'node:path';
47
+ import { join, dirname } from 'node:path';
48
48
 
49
49
  import * as core from './core.js';
50
50
 
@@ -61,15 +61,6 @@ const asyncIo = {
61
61
  readFileSync: (p, encoding) => fsp.readFile(p, encoding),
62
62
  readdirSync: (p) => fsp.readdir(p),
63
63
  statSync: (p) => fsp.stat(p),
64
- // Header-only binary read for the image dimension sniffer (64 KiB cap).
65
- readBuffer: async (p, max) => {
66
- const fh = await fsp.open(p, 'r');
67
- try {
68
- const buf = Buffer.alloc(Math.min(max, 65536));
69
- const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
70
- return buf.subarray(0, bytesRead);
71
- } finally { await fh.close(); }
72
- },
73
64
  };
74
65
 
75
66
  /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
@@ -164,14 +155,22 @@ export function createRouteRegistrar(webServer, opts = {}) {
164
155
  // ── Inventory (cached) ──────────────────────────────────────────────────
165
156
  let cache = null; // { at: number, payload: object }
166
157
  let discoveryCache = null; // { at: number, value: object }
158
+ let discovering = null; // in-flight discovery (cold-start dedupe)
167
159
  let building = null; // in-flight build promise (request coalescing)
168
160
 
169
161
  async function getDiscovery(force) {
170
162
  const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
171
163
  if (!force && fresh) return discoveryCache.value;
172
- const value = await discoverFn();
173
- discoveryCache = { at: Date.now(), value };
174
- return value;
164
+ // Concurrent callers (registration warm-up + first request) share ONE
165
+ // discovery run instead of racing reg.exe twice at cold start.
166
+ if (discovering && !force) return discovering;
167
+ discovering = discoverFn()
168
+ .then((value) => {
169
+ discoveryCache = { at: Date.now(), value };
170
+ return value;
171
+ })
172
+ .finally(() => { if (!force) discovering = null; });
173
+ return discovering;
175
174
  }
176
175
 
177
176
  async function buildInventory(force) {
@@ -189,8 +188,14 @@ export function createRouteRegistrar(webServer, opts = {}) {
189
188
  if (!force && cache && Date.now() - cache.at < inventoryTtlMs) {
190
189
  return Promise.resolve(cache.payload);
191
190
  }
192
- // Coalesce concurrent builds (double-clicked 刷新, parallel clients).
193
- if (building) return building;
191
+ // Coalesce concurrent NON-forced builds (double-clicked 刷新 within the
192
+ // same tick, parallel clients). A forced refresh must never be folded
193
+ // into a non-forced in-flight build — the whole point of ?refresh=1 is
194
+ // the remint, so it waits for the current build and then rebuilds.
195
+ if (building) {
196
+ if (!force) return building;
197
+ return building.catch(() => {}).then(() => getInventory(true));
198
+ }
194
199
  building = buildInventory(force)
195
200
  .then((payload) => { cache = { at: Date.now(), payload }; return payload; })
196
201
  .finally(() => { building = null; });
@@ -216,10 +221,18 @@ export function createRouteRegistrar(webServer, opts = {}) {
216
221
  res.end();
217
222
  return;
218
223
  }
219
- res.setHeader('Content-Type', core.mimeFor(absPath));
224
+ const mime = core.mimeFor(absPath);
225
+ res.setHeader('Content-Type', mime);
220
226
  res.setHeader('Accept-Ranges', 'bytes');
221
227
  res.setHeader('Cache-Control', 'no-store');
222
228
  res.setHeader('X-Content-Type-Options', 'nosniff');
229
+ if (mime === 'text/html' || mime === 'image/svg+xml') {
230
+ // Script-capable documents (web wallpapers, SVG previews) get a
231
+ // sandboxed CSP even on direct top-level navigation, so they can
232
+ // never execute against the DSH origin. The client's iframe sandbox
233
+ // already covers embedding; this is the server-side backstop.
234
+ res.setHeader('Content-Security-Policy', 'sandbox');
235
+ }
223
236
 
224
237
  const range = core.parseRangeHeader(req.headers.range, st.size);
225
238
  if (range && range.error) {
@@ -246,6 +259,16 @@ export function createRouteRegistrar(webServer, opts = {}) {
246
259
  // leave the already-announced Content-Length unsatisfied and the
247
260
  // client hanging — destroy the response instead.
248
261
  stream.on('error', () => { res.destroy(); });
262
+ // A file that SHRANK between stat and open streams fewer bytes than
263
+ // the announced Content-Length without any open error — count what
264
+ // actually went out and tear the response down on a short read so the
265
+ // client sees a retryable network failure, not a silent truncation.
266
+ const expected = range ? range.end - range.start + 1 : st.size;
267
+ let sent = 0;
268
+ stream.on('data', (chunk) => { sent += chunk.length; });
269
+ stream.on('end', () => {
270
+ if (sent !== expected) res.destroy();
271
+ });
249
272
  stream.pipe(res);
250
273
  }).catch(() => {
251
274
  res.statusCode = 404;
@@ -254,6 +277,27 @@ export function createRouteRegistrar(webServer, opts = {}) {
254
277
  });
255
278
  }
256
279
 
280
+ /**
281
+ * Real-path containment check (symlink defense). The lexical
282
+ * isInsideDir() passes above reject `..`-style escapes, but a symlink
283
+ * placed inside a wallpaper dir pointing outside would sail through
284
+ * lexically and be followed at open time. Resolve BOTH sides with
285
+ * realpath and re-check: a symlinked target resolves outside the real
286
+ * root and is refused. Resolves null when the path does not exist.
287
+ */
288
+ const realRootCache = new Map(); // token → realpath(root) | null
289
+ async function realPathWithin(entry, candidateAbs) {
290
+ let rootReal = realRootCache.get(entry.key || entry.abs);
291
+ if (rootReal === undefined) {
292
+ rootReal = await fsp.realpath(entry.rootDir || dirname(entry.abs)).catch(() => null);
293
+ realRootCache.set(entry.key || entry.abs, rootReal);
294
+ }
295
+ if (!rootReal) return null;
296
+ const realAbs = await fsp.realpath(candidateAbs).catch(() => null);
297
+ if (!realAbs || !core.isInsideDir(rootReal, realAbs)) return null;
298
+ return realAbs;
299
+ }
300
+
257
301
  // ── Routes ──────────────────────────────────────────────────────────────
258
302
  const disposers = [];
259
303
 
@@ -262,14 +306,24 @@ export function createRouteRegistrar(webServer, opts = {}) {
262
306
  path: `${BASE}/inventory`,
263
307
  handler: (req, res) => {
264
308
  if (req.method !== 'GET' && req.method !== 'HEAD') {
309
+ res.setHeader('Allow', 'GET, HEAD');
265
310
  sendJson(req, res, 405, { error: 'method not allowed' });
266
311
  return;
267
312
  }
268
- const url = new URL(req.url || '/', 'http://x');
313
+ let url;
314
+ try {
315
+ url = new URL(req.url || '/', 'http://x');
316
+ } catch {
317
+ sendJson(req, res, 400, { error: 'bad request' });
318
+ return;
319
+ }
269
320
  getInventory(url.searchParams.get('refresh') === '1')
270
321
  .then((payload) => sendJson(req, res, 200, payload))
271
322
  .catch((err) => {
272
- sendJson(req, res, 500, { error: String(err && err.message ? err.message : err) });
323
+ // Loopback-only, but the raw message can carry absolute paths —
324
+ // genericise for the wire, keep detail in the server log.
325
+ console.error('[we-background] inventory build failed:', err && err.stack || err);
326
+ sendJson(req, res, 500, { error: 'inventory build failed' });
273
327
  });
274
328
  },
275
329
  }));
@@ -281,6 +335,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
281
335
  path: `${BASE}/${seg}`,
282
336
  handler: (req, res) => {
283
337
  if (req.method !== 'GET' && req.method !== 'HEAD') {
338
+ res.setHeader('Allow', 'GET, HEAD');
284
339
  sendJson(req, res, 405, { error: 'method not allowed' });
285
340
  return;
286
341
  }
@@ -333,7 +388,17 @@ export function createRouteRegistrar(webServer, opts = {}) {
333
388
  return;
334
389
  }
335
390
  }
336
- serveFile(abs, req, res);
391
+ // Symlink backstop: serve only what realpath still keeps inside the
392
+ // entry's real root (404 — indistinguishable from a vanished file).
393
+ realPathWithin(entry, abs).then((real) => {
394
+ if (!real) {
395
+ res.statusCode = 404;
396
+ res.setHeader('Content-Length', '0');
397
+ res.end();
398
+ return;
399
+ }
400
+ serveFile(real, req, res);
401
+ });
337
402
  },
338
403
  }));
339
404
  }
@@ -4,10 +4,14 @@
4
4
  * mirrored on the default export.
5
5
  */
6
6
 
7
+ /**
8
+ * One inventory entry. Scene/application wallpapers are filtered out at the
9
+ * source — the wire only ever carries the browser-renderable kinds.
10
+ */
7
11
  export interface WallpaperSummary {
8
12
  id: string;
9
13
  title: string;
10
- type: 'scene' | 'video' | 'web' | 'application';
14
+ type: 'video' | 'web';
11
15
  playable: boolean;
12
16
  media: string | null;
13
17
  preview: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
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",