wallpaper-engine-dsh 0.1.7 → 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,16 +2,16 @@
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.
13
13
  - **Legibility on any wallpaper** — the veil opposes the text colour (white veil in light theme, black in dark) and a Smart veil toggle samples the wallpaper's brightness every few seconds, automatically strengthening the veil when a scene gets too bright or too dark for the current theme. Dropdowns, menus, and dialogs keep DSH's opaque surfaces untouched.
14
- - **Canvas fit** — Cover/Contain/Stretch/Original sizing plus five-way alignment for video and still wallpapers.
14
+ - **Manual crop** — pick a base layout (Cover/Contain/Stretch/Original), then frame it yourself: **Adjust** opens the live wallpaper fullscreen where you drag to pan and scroll to zoom; **Reset crop** restores it.
15
15
  - **Resource monitor** — FPS readout with a low-framerate hint, plus auto-pause when the tab is hidden or the battery runs low (both optional).
16
16
  - **Bilingual** — the settings UI follows the shell's Language preference (Chinese/English), with a manual override in the picker.
17
17
  - **Crossfade** — wallpaper switches and clearing fade smoothly instead of snapping.
@@ -35,7 +35,7 @@ Restart `dsh web`, then open **Settings → General → Wallpaper Engine**.
35
35
 
36
36
  1. Pick a wallpaper from the thumbnail grid — it fades in behind the app. Use the search box to narrow large libraries.
37
37
  2. **Pause/Play** controls video playback; **Close** fades the wallpaper out; **Refresh** rescans the library (new workshop subscriptions appear without reloading the page).
38
- 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
+ 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** row sets the base layout for video and still wallpapers — **Adjust** then lets you pan and zoom the crop directly on the canvas.
39
39
  4. Create rotation lists with **New**, fill them from the grid or import a WE playlist, then enable **Auto-rotate**.
40
40
  5. The resource row shows the current frame rate and offers two auto-pause switches. `prefers-reduced-motion` starts videos paused.
41
41
  6. The Language control switches the picker between Chinese and English; on Auto it follows the shell setting.
@@ -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.
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,16 +2,16 @@
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
  - **四个实时滑杆** —— 壁纸模糊、暗化、边框、玻璃,即时生效并持久保存。
13
13
  - **任意壁纸都可读** —— 遮罩颜色与文字颜色相对(浅色主题提亮、深色主题压暗);"智能可视"开关每隔几秒采样一次壁纸亮度,画面过亮或过暗时自动加强遮罩。下拉菜单、弹窗等浮层保持 DSH 原生不透明表面,不受影响。
14
- - **画布排布** —— 视频与静态壁纸支持填充/适应/拉伸/原始四种排布和五向对齐。
14
+ - **手动裁剪** —— 先选基础排布(填充/适应/拉伸/原始),再自己取景:点「调整画面」把壁纸全屏展开,拖动平移、滚轮缩放,「重置裁剪」一键还原。
15
15
  - **资源监控** —— 帧率读数与低帧率提示,页面隐藏或电量不足时自动暂停(均可开关)。
16
16
  - **中英双语** —— 设置界面跟随 DSH 的"语言"偏好自动切换,也可在面板内手动指定。
17
17
  - **交叉淡入** —— 切换与关闭都平滑过渡,不生硬跳变。
@@ -35,7 +35,7 @@ dsh plugin --profile web add link:<克隆文件夹的绝对路径>
35
35
 
36
36
  1. 从缩略图网格选择壁纸,它会在界面背后淡入。壁纸库较大时用搜索框快速定位。
37
37
  2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库,新订阅的创意工坊壁纸无需刷新页面即可出现。
38
- 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布/对齐** 一行设置视频与静态壁纸在画布上的摆放(填充/适应/拉伸/原始,五个锚点)。
38
+ 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布** 一行决定视频与静态壁纸的基础摆放(填充/适应/拉伸/原始),点「调整画面」可在画布上直接拖动平移、滚轮缩放取景。
39
39
  4. 用 **新建** 创建轮播列表,从网格挑选或导入 WE 播放列表,然后开启 **自动轮转**。
40
40
  5. 资源行显示当前帧率,并提供两个自动暂停开关。系统开启 `prefers-reduced-motion` 时视频默认暂停。
41
41
  6. "语言"控件在中英文之间切换面板;选"自动"则跟随 DSH 的语言设置。
@@ -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
@@ -48,7 +48,6 @@ window.__ModuleLoader__.load({
48
48
  noPreview: "无预览",
49
49
  badgeVideo: "视频",
50
50
  badgeWeb: "网页",
51
- badgeStill: "静态",
52
51
  rotationList: "轮播列表",
53
52
  selectList: "— 选择轮播列表 —",
54
53
  noLists: "— 暂无轮播列表 —",
@@ -90,12 +89,10 @@ window.__ModuleLoader__.load({
90
89
  fitContain: "适应",
91
90
  fitFill: "拉伸",
92
91
  fitNone: "原始",
93
- position: "对齐",
94
- posCenter: "居中",
95
- posTop: "顶部",
96
- posBottom: "底部",
97
- posLeft: "居左",
98
- posRight: "居右",
92
+ adjust: "调整画面",
93
+ adjustDone: "完成",
94
+ adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
95
+ resetCrop: "重置裁剪",
99
96
  statusGroup: (name, total, usable, interval, order) =>
100
97
  "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
101
98
  statusUsable: (n) => n + " 张可用壁纸",
@@ -128,7 +125,6 @@ window.__ModuleLoader__.load({
128
125
  noPreview: "No preview",
129
126
  badgeVideo: "Video",
130
127
  badgeWeb: "Web",
131
- badgeStill: "Still",
132
128
  rotationList: "Rotation list",
133
129
  selectList: "— Select a list —",
134
130
  noLists: "— No lists yet —",
@@ -170,12 +166,10 @@ window.__ModuleLoader__.load({
170
166
  fitContain: "Contain",
171
167
  fitFill: "Stretch",
172
168
  fitNone: "Original",
173
- position: "Align",
174
- posCenter: "Center",
175
- posTop: "Top",
176
- posBottom: "Bottom",
177
- posLeft: "Left",
178
- posRight: "Right",
169
+ adjust: "Adjust",
170
+ adjustDone: "Done",
171
+ adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
172
+ resetCrop: "Reset crop",
179
173
  statusGroup: (name, total, usable, interval, order) =>
180
174
  "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
181
175
  statusUsable: (n) => n + " usable wallpapers",
@@ -225,11 +219,12 @@ window.__ModuleLoader__.load({
225
219
  autoScrim: true,
226
220
  lang: "auto",
227
221
  fit: "cover",
228
- position: "center",
222
+ zoom: 1,
223
+ offsetX: 0,
224
+ offsetY: 0,
229
225
  };
230
226
 
231
227
  const FIT_MODES = ["cover", "contain", "fill", "none"];
232
- const POSITIONS = ["center", "top", "bottom", "left", "right"];
233
228
 
234
229
  // ── Persisted selection ─────────────────────────────────────────────────────
235
230
  function clampNum(v, lo, hi, fallback) {
@@ -276,7 +271,9 @@ window.__ModuleLoader__.load({
276
271
  autoScrim: o.autoScrim !== false,
277
272
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
278
273
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
279
- position: POSITIONS.indexOf(o.position) >= 0 ? o.position : DEFAULTS.position,
274
+ zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
275
+ offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
276
+ offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
280
277
  };
281
278
  } catch {
282
279
  return { id: "", ...DEFAULTS };
@@ -288,6 +285,7 @@ window.__ModuleLoader__.load({
288
285
  ...readPersisted(),
289
286
  url: null,
290
287
  type: null,
288
+ adjusting: false, // crop-adjust overlay open (not persisted)
291
289
  playing: !REDUCED_MOTION,
292
290
  loading: false,
293
291
  rotationTimer: null,
@@ -328,24 +326,25 @@ window.__ModuleLoader__.load({
328
326
  autoScrim: selection.autoScrim,
329
327
  lang: selection.lang,
330
328
  fit: selection.fit,
331
- position: selection.position,
329
+ zoom: selection.zoom,
330
+ offsetX: selection.offsetX,
331
+ offsetY: selection.offsetY,
332
332
  }));
333
333
  } catch { /* storage full / blocked → session-only state, not fatal */ }
334
334
  }
335
335
 
336
336
  // ── Wallpaper predicates ─────────────────────────────────────────────────────
337
337
  // playable: real media (video/web) renders live.
338
- // 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.
339
342
  function isPlayable(w) {
340
343
  return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
341
344
  }
342
345
 
343
- function isStatic(w) {
344
- return Boolean(w && !isPlayable(w) && typeof w.preview === "string" && w.preview);
345
- }
346
-
347
346
  function isSelectable(w) {
348
- return isPlayable(w) || isStatic(w);
347
+ return isPlayable(w);
349
348
  }
350
349
 
351
350
  function selectableInventory() {
@@ -353,13 +352,22 @@ window.__ModuleLoader__.load({
353
352
  }
354
353
 
355
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
+
356
362
  async function loadInventory(forceRefresh) {
363
+ const epoch = ++inventoryEpoch;
357
364
  selection.loading = true;
358
365
  emit();
359
366
  try {
360
367
  const res = await fetch(forceRefresh ? INVENTORY_URL + "?refresh=1" : INVENTORY_URL, { cache: "no-store" });
361
368
  if (!res.ok) throw new Error("inventory HTTP " + res.status);
362
369
  const data = await res.json();
370
+ if (disposed || epoch !== inventoryEpoch) return;
363
371
  selection.inventory = {
364
372
  installDir: data.installDir || null,
365
373
  wallpapers: Array.isArray(data.wallpapers) ? data.wallpapers : [],
@@ -369,11 +377,13 @@ window.__ModuleLoader__.load({
369
377
  error: null,
370
378
  };
371
379
  } catch (err) {
380
+ if (disposed || epoch !== inventoryEpoch) return;
372
381
  selection.inventory = {
373
382
  installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [],
374
383
  error: String(err && err.message ? err.message : err),
375
384
  };
376
385
  }
386
+ if (disposed || epoch !== inventoryEpoch) return;
377
387
  selection.loading = false;
378
388
  selection.loaded = true;
379
389
 
@@ -582,6 +592,7 @@ window.__ModuleLoader__.load({
582
592
  }
583
593
 
584
594
  function applySelection(id) {
595
+ if (selection.adjusting) exitAdjust();
585
596
  selection.id = id || "";
586
597
  persistSelection();
587
598
  const w = selection.id
@@ -590,9 +601,9 @@ window.__ModuleLoader__.load({
590
601
  if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
591
602
  selection.url = w.media;
592
603
  selection.type = w.type; // "video" | "web"
593
- } else if (w && w.preview) {
594
- // Static path: scene/application wallpapers, and playable wallpapers
595
- // 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.
596
607
  selection.url = w.preview;
597
608
  selection.type = "image";
598
609
  } else {
@@ -629,11 +640,10 @@ window.__ModuleLoader__.load({
629
640
  // Set by a media error handler to force a remount with fresh URLs; cleared
630
641
  // once the remount happens.
631
642
  let forceRemount = false;
632
- let lastMediaRecoveryAt = 0;
633
- // Media failure bookkeeping: the first error for a wallpaper may just be a
634
- // stale token (fixed by a refresh+remount); a SECOND error for the same id
635
- // means the bytes themselves are unusable (e.g. an mkv/avi Chromium cannot
636
- // 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
637
647
  const mediaFailures = new Map(); // wallpaperId → consecutive failure count
638
648
  const demotedToPreview = new Set(); // wallpaperIds now rendered as stills
639
649
 
@@ -659,7 +669,8 @@ window.__ModuleLoader__.load({
659
669
  media.setAttribute("title", S().iframeTitle);
660
670
  media.className = "webg-media webg-iframe";
661
671
  } else {
662
- // 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.
663
674
  media = document.createElement("img");
664
675
  media.src = sel.url;
665
676
  media.alt = "";
@@ -667,33 +678,169 @@ window.__ModuleLoader__.load({
667
678
  media.setAttribute("decoding", "async");
668
679
  media.className = "webg-media";
669
680
  }
670
- // First luminance sample as soon as the frame is decodable (video/still
671
- // only — iframe contents are opaque to us). Later samples ride the timer.
672
- if (sel.type === "video" || sel.type === "image") {
673
- media.addEventListener(sel.type === "video" ? "loadeddata" : "load", () => {
674
- sampleWallpaperLuminance();
675
- applyEffects();
676
- });
677
- }
681
+ attachStillSampling(media, sel);
682
+ attachMediaError(media, sel);
683
+ return media;
684
+ }
685
+
686
+ function attachStillSampling(media, sel) {
687
+ const type = (sel && sel.type) || (media.tagName === "IMG" ? "image" : null);
688
+ if (type !== "video" && type !== "image") return;
689
+ media.addEventListener(type === "video" ? "loadeddata" : "load", () => {
690
+ sampleWallpaperLuminance();
691
+ applyEffects();
692
+ });
693
+ }
694
+
695
+ function attachMediaError(media, sel) {
678
696
  media.addEventListener("error", () => {
679
697
  const id = sel.id;
680
698
  const count = (mediaFailures.get(id) || 0) + 1;
681
699
  mediaFailures.set(id, count);
682
700
  const w = selection.inventory.wallpapers.find((x) => x.id === id);
683
701
  if (count === 1) {
684
- // 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).
685
706
  const now = Date.now();
686
- if (now - lastMediaRecoveryAt < 10000) return;
687
- lastMediaRecoveryAt = now;
707
+ if (now - (lastRecoveryAt.get(id) || 0) < 10000) return;
708
+ lastRecoveryAt.set(id, now);
688
709
  forceRemount = true;
689
710
  loadInventory(true);
690
711
  } else if (w && w.preview && !demotedToPreview.has(id)) {
691
712
  // The media itself is unusable here — fall back to the still preview.
692
713
  demotedToPreview.add(id);
693
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("");
694
719
  }
695
720
  });
696
- return media;
721
+ }
722
+
723
+ // ── Manual crop: drag-to-pan / wheel-to-zoom in an adjust overlay ───────────
724
+ // The layer normally sits at z-index -2 behind the entire app, so it can
725
+ // never receive pointer events. Adjust mode moves the LIVE layer node into a
726
+ // topmost transparent overlay — the user edits the real thing, WYSIWYG.
727
+ let adjustOverlay = null;
728
+ let adjustCleanup = null;
729
+
730
+ function exitAdjust() {
731
+ if (!adjustOverlay) return;
732
+ const layer = document.getElementById(LAYER_ID);
733
+ if (adjustCleanup) adjustCleanup();
734
+ if (layer) document.body.appendChild(layer); // move back behind the app
735
+ adjustOverlay.remove();
736
+ adjustOverlay = null;
737
+ adjustCleanup = null;
738
+ selection.adjusting = false;
739
+ persistSelection();
740
+ emit();
741
+ }
742
+
743
+ function enterAdjust() {
744
+ const layer = document.getElementById(LAYER_ID);
745
+ if (!layer || adjustOverlay) return;
746
+ const overlay = document.createElement("div");
747
+ overlay.className = "webg-adjust";
748
+ overlay.appendChild(layer); // moves the live node into the overlay
749
+
750
+ const bar = document.createElement("div");
751
+ bar.className = "webg-adjust-bar";
752
+ const hint = document.createElement("span");
753
+ hint.className = "webg-adjust-hint";
754
+ hint.textContent = S().adjustHint;
755
+ const done = document.createElement("button");
756
+ done.type = "button";
757
+ done.className = "webg-btn webg-btn--primary";
758
+ done.textContent = S().adjustDone;
759
+ bar.appendChild(hint);
760
+ bar.appendChild(done);
761
+ overlay.appendChild(bar);
762
+ document.body.appendChild(overlay);
763
+ adjustOverlay = overlay;
764
+ selection.adjusting = true;
765
+
766
+ // translate() in the transform list is applied AFTER scale() (right-to-
767
+ // left), so pan deltas are independent of zoom: dx px = dx/width %.
768
+ let drag = null;
769
+ const onPointerDown = (e) => {
770
+ if (e.target === done || bar.contains(e.target)) return;
771
+ drag = { x: e.clientX, y: e.clientY };
772
+ if (overlay.setPointerCapture && e.pointerId !== undefined) {
773
+ try { overlay.setPointerCapture(e.pointerId); } catch {}
774
+ }
775
+ };
776
+ const onPointerMove = (e) => {
777
+ if (!drag) return;
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;
783
+ // True clamping (clampNum is a validate-or-fallback for persisted data).
784
+ selection.offsetX = Math.min(200, Math.max(-200,
785
+ selection.offsetX + ((e.clientX - drag.x) / w) * 100));
786
+ selection.offsetY = Math.min(200, Math.max(-200,
787
+ selection.offsetY + ((e.clientY - drag.y) / h) * 100));
788
+ drag = { x: e.clientX, y: e.clientY };
789
+ applyEffects();
790
+ };
791
+ const onPointerUp = () => {
792
+ if (!drag) return;
793
+ drag = null;
794
+ persistSelection();
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
+ };
803
+ const onWheel = (e) => {
804
+ if (typeof e.preventDefault === "function") e.preventDefault();
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)));
810
+ selection.zoom = Math.round(next * 100) / 100;
811
+ applyEffects();
812
+ persistSelection();
813
+ };
814
+ const onKey = (e) => { if (e.key === "Escape") exitAdjust(); };
815
+ const onDone = () => exitAdjust();
816
+
817
+ overlay.addEventListener("pointerdown", onPointerDown);
818
+ overlay.addEventListener("pointermove", onPointerMove);
819
+ overlay.addEventListener("pointerup", onPointerUp);
820
+ overlay.addEventListener("pointercancel", onPointerCancel);
821
+ overlay.addEventListener("wheel", onWheel, { passive: false });
822
+ done.addEventListener("click", onDone);
823
+ document.addEventListener("keydown", onKey);
824
+
825
+ adjustCleanup = () => {
826
+ overlay.removeEventListener("pointerdown", onPointerDown);
827
+ overlay.removeEventListener("pointermove", onPointerMove);
828
+ overlay.removeEventListener("pointerup", onPointerUp);
829
+ overlay.removeEventListener("pointercancel", onPointerCancel);
830
+ overlay.removeEventListener("wheel", onWheel);
831
+ done.removeEventListener("click", onDone);
832
+ document.removeEventListener("keydown", onKey);
833
+ };
834
+ emit();
835
+ }
836
+
837
+ function resetCrop() {
838
+ selection.zoom = 1;
839
+ selection.offsetX = 0;
840
+ selection.offsetY = 0;
841
+ persistSelection();
842
+ applyEffects();
843
+ emit();
697
844
  }
698
845
 
699
846
  // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
@@ -801,6 +948,7 @@ window.__ModuleLoader__.load({
801
948
  const g = sampleCanvas.getContext("2d", { willReadFrequently: true });
802
949
  g.drawImage(media, 0, 0, 16, 16);
803
950
  const d = g.getImageData(0, 0, 16, 16).data;
951
+ if (!d || d.length < 4) return; // unreadable frame → keep previous sample
804
952
  let sum = 0;
805
953
  for (let i = 0; i < d.length; i += 4) {
806
954
  sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
@@ -832,13 +980,25 @@ window.__ModuleLoader__.load({
832
980
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
833
981
  s.setProperty("--webg-glass-brightness", "1.04");
834
982
  s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
983
+ // At 0 blur, keep the media OUT of the filter pipeline entirely: even
984
+ // blur(0px) forces an extra raster/compositing pass, and the noise overlay
985
+ // would still occupy a layer at opacity 0. filter:none + display:none keep
986
+ // the pixels bit-exact and the layer list minimal.
987
+ s.setProperty("--webg-media-filter",
988
+ selection.wallpaperBlur > 0
989
+ ? "blur(" + selection.wallpaperBlur + "px) saturate(1.08)"
990
+ : "none");
991
+ s.setProperty("--webg-noise-display", selection.wallpaperBlur > 0 ? "block" : "none");
835
992
  // Dither-noise strength scales with blur: invisible at 0, ~4% at 60px.
836
993
  s.setProperty("--webg-noise-opacity",
837
994
  selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
838
- // Canvas fit: object-fit/position for video and still wallpapers (iframes
839
- // ignore object-fit — web wallpapers always fill their layer).
995
+ // Canvas fit: object-fit base mode plus the user's manual crop — pan
996
+ // offsets (percent of the media box) and zoom, applied as a transform so
997
+ // no remount is ever needed while dragging.
840
998
  s.setProperty("--webg-fit", selection.fit);
841
- s.setProperty("--webg-position", selection.position);
999
+ s.setProperty("--webg-zoom", String(selection.zoom));
1000
+ s.setProperty("--webg-offset-x", selection.offsetX + "%");
1001
+ s.setProperty("--webg-offset-y", selection.offsetY + "%");
842
1002
 
843
1003
  // Write the scrim colour directly too, then force a synchronous layout, so
844
1004
  // slider feedback lands on this frame even on stalled compositors.
@@ -857,9 +1017,13 @@ window.__ModuleLoader__.load({
857
1017
  s.removeProperty("--webg-saturate");
858
1018
  s.removeProperty("--webg-glass-brightness");
859
1019
  s.removeProperty("--webg-wallpaper-blur");
1020
+ s.removeProperty("--webg-media-filter");
1021
+ s.removeProperty("--webg-noise-display");
860
1022
  s.removeProperty("--webg-noise-opacity");
861
1023
  s.removeProperty("--webg-fit");
862
- s.removeProperty("--webg-position");
1024
+ s.removeProperty("--webg-zoom");
1025
+ s.removeProperty("--webg-offset-x");
1026
+ s.removeProperty("--webg-offset-y");
863
1027
  const scrim = document.getElementById(SCRIM_ID);
864
1028
  if (scrim) scrim.style.background = "";
865
1029
  }
@@ -886,7 +1050,9 @@ window.__ModuleLoader__.load({
886
1050
  min: String(min), max: String(max), step: String(step),
887
1051
  value: String(value),
888
1052
  style: { "--webg-p": String(pct) },
889
- 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.
890
1056
  onChange: set,
891
1057
  }),
892
1058
  React.createElement("span", { className: "webg-hint webg-value" }, suffix),
@@ -895,8 +1061,7 @@ window.__ModuleLoader__.load({
895
1061
 
896
1062
  function typeBadge(w, t) {
897
1063
  if (w.type === "video") return t.badgeVideo;
898
- if (w.type === "web") return t.badgeWeb;
899
- return t.badgeStill;
1064
+ return t.badgeWeb;
900
1065
  }
901
1066
 
902
1067
  function matchesQuery(w, q) {
@@ -936,6 +1101,7 @@ window.__ModuleLoader__.load({
936
1101
  // preview after decode failures.
937
1102
  demotedToPreview.clear();
938
1103
  mediaFailures.clear();
1104
+ lastRecoveryAt.clear();
939
1105
  loadInventory(true);
940
1106
  };
941
1107
  const onLangChange = (e) => {
@@ -1022,12 +1188,6 @@ window.__ModuleLoader__.load({
1022
1188
  applyEffects();
1023
1189
  emit();
1024
1190
  };
1025
- const onPosition = (e) => {
1026
- selection.position = POSITIONS.indexOf(e.target.value) >= 0 ? e.target.value : "center";
1027
- persistSelection();
1028
- applyEffects();
1029
- emit();
1030
- };
1031
1191
 
1032
1192
  if (!sel.loaded) {
1033
1193
  return React.createElement("div", { className: "webg-picker" },
@@ -1157,7 +1317,10 @@ window.__ModuleLoader__.load({
1157
1317
  value: String(editing.interval),
1158
1318
  onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1159
1319
  },
1160
- ...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) =>
1161
1324
  React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1162
1325
  )),
1163
1326
  React.createElement("span", { className: "webg-hint webg-label" }, t.order),
@@ -1255,16 +1418,18 @@ window.__ModuleLoader__.load({
1255
1418
  disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1256
1419
  title: t.intervalTitle,
1257
1420
  },
1258
- ...INTERVALS.map((minutes) =>
1259
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1260
- )),
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
+ )),
1261
1425
  !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1262
1426
  sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1263
1427
  ),
1264
1428
  // ── Effects ──
1265
1429
  sel.id && React.createElement(React.Fragment, null,
1266
1430
  // Canvas fit only matters for video/still wallpapers — an iframe
1267
- // ignores object-fit and always fills its layer.
1431
+ // ignores object-fit and always fills its layer. Manual crop (drag /
1432
+ // wheel in the adjust overlay) replaced the old fixed anchor select.
1268
1433
  sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1269
1434
  React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1270
1435
  React.createElement("select", {
@@ -1275,19 +1440,19 @@ window.__ModuleLoader__.load({
1275
1440
  ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1276
1441
  React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1277
1442
  )),
1278
- React.createElement("span", { className: "webg-hint webg-label" }, t.position),
1279
- React.createElement("select", {
1280
- className: "webg-select webg-position-select",
1281
- value: sel.position,
1282
- onChange: onPosition,
1283
- },
1284
- ...[t.posCenter, t.posTop, t.posBottom, t.posLeft, t.posRight].map((label, i) =>
1285
- React.createElement("option", { key: POSITIONS[i], value: POSITIONS[i] }, label),
1286
- )),
1443
+ React.createElement("button", {
1444
+ className: "webg-btn", type: "button", onClick: () => enterAdjust(),
1445
+ disabled: sel.adjusting,
1446
+ }, t.adjust),
1447
+ (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1448
+ React.createElement("button", {
1449
+ className: "webg-btn", type: "button", onClick: resetCrop,
1450
+ }, t.resetCrop),
1287
1451
  ),
1288
1452
  React.createElement("div", { className: "webg-effects" },
1289
1453
  SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1290
- 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) + "%"),
1291
1456
  SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1292
1457
  SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1293
1458
  ),
@@ -1361,18 +1526,43 @@ window.__ModuleLoader__.load({
1361
1526
  height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1362
1527
  max-width: none; max-height: none;
1363
1528
  object-fit: var(--webg-fit, cover);
1364
- object-position: var(--webg-position, center);
1529
+ object-position: center;
1365
1530
  display: block;
1366
1531
  background: transparent; border: 0;
1367
- filter: blur(var(--webg-wallpaper-blur, 0px)) saturate(1.08);
1532
+ filter: var(--webg-media-filter, none);
1533
+ /* Manual crop: pan offsets are % of the media box (independent of zoom
1534
+ because translate is listed before scale), zoom is centered. */
1535
+ transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%)) scale(var(--webg-zoom, 1));
1536
+ transform-origin: center center;
1537
+ }
1538
+
1539
+ /* Crop-adjust overlay: topmost, transparent — only its hint bar is chrome.
1540
+ The live layer is moved INSIDE, so editing is what-you-see. */
1541
+ .webg-adjust {
1542
+ position: fixed; inset: 0; z-index: 2147483000;
1543
+ cursor: grab; touch-action: none;
1544
+ }
1545
+ .webg-adjust:active { cursor: grabbing; }
1546
+ .webg-adjust .webg-layer { position: absolute; z-index: 0; }
1547
+ .webg-adjust-bar {
1548
+ position: absolute; top: 18px; left: 50%; transform: translateX(-50%);
1549
+ z-index: 1; cursor: default;
1550
+ display: flex; align-items: center; gap: 12px;
1551
+ padding: 8px 8px 8px 14px; border-radius: 12px;
1552
+ background: var(--dsw-alias-bg-overlay, rgba(30, 32, 38, 0.85));
1553
+ color: var(--dsw-alias-label-primary, inherit);
1554
+ backdrop-filter: blur(20px);
1555
+ -webkit-backdrop-filter: blur(20px);
1556
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
1557
+ font-size: 12px; white-space: nowrap;
1368
1558
  }
1369
1559
 
1370
1560
  /* Gaussian blur alone shows banding on smooth gradients; a whisper of
1371
1561
  fractal noise over the layer dithers it away (iOS does the same inside
1372
- its frosted materials). Opacity rides the blur radius — zero blur, zero
1373
- noise. */
1562
+ its frosted materials). display:none at zero blur — no layer, no cost. */
1374
1563
  .webg-layer::after {
1375
1564
  content: ""; position: absolute; inset: 0; pointer-events: none;
1565
+ display: var(--webg-noise-display, none);
1376
1566
  opacity: var(--webg-noise-opacity, 0);
1377
1567
  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>');
1378
1568
  background-size: 120px 120px;
@@ -1584,8 +1774,7 @@ window.__ModuleLoader__.load({
1584
1774
  font-size: 12px; color: var(--dsw-alias-label-secondary, #888);
1585
1775
  }
1586
1776
  .webg-card-type {
1587
- position: absolute; top: 4px; right: 4px;
1588
- padding: 1px 6px; border-radius: 4px;
1777
+ position: absolute; top: 4px; right: 4px; padding: 1px 6px; border-radius: 4px;
1589
1778
  font-size: 10px; line-height: 1.5;
1590
1779
  color: #fff; background: rgba(0, 0, 0, 0.5);
1591
1780
  }
@@ -1699,8 +1888,14 @@ window.__ModuleLoader__.load({
1699
1888
  observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1700
1889
  }
1701
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;
1702
1894
  unsubLayers();
1703
1895
  unsubEffects();
1896
+ // Close the crop-adjust overlay if it is open (moves the layer back,
1897
+ // removes the bar and its listeners) before tearing layers down.
1898
+ exitAdjust();
1704
1899
  if (samplerId !== null) window.clearInterval(samplerId);
1705
1900
  if (observer) observer.disconnect();
1706
1901
  sampledLuminance = null;
package/lib/core.js CHANGED
@@ -48,6 +48,15 @@ export function parseKeyValues(text) {
48
48
  const src = String(text);
49
49
  const n = src.length;
50
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
+ };
51
60
 
52
61
  const skipSpace = () => {
53
62
  for (;;) {
@@ -83,6 +92,18 @@ export function parseKeyValues(text) {
83
92
  const value = () => {
84
93
  skipSpace();
85
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
+ }
86
107
  i++;
87
108
  const obj = {};
88
109
  for (;;) {
@@ -90,8 +111,9 @@ export function parseKeyValues(text) {
90
111
  if (i >= n) break;
91
112
  if (src[i] === '}') { i++; break; }
92
113
  const key = token();
93
- obj[key] = value();
114
+ safeAssign(obj, key, value());
94
115
  }
116
+ depth--;
95
117
  return obj;
96
118
  };
97
119
 
@@ -101,7 +123,7 @@ export function parseKeyValues(text) {
101
123
  if (i >= n) break;
102
124
  const key = token();
103
125
  if (!key) { i++; continue; }
104
- root[key] = value();
126
+ safeAssign(root, key, value());
105
127
  }
106
128
  return root;
107
129
  }
@@ -446,7 +468,8 @@ export function pathKey(file) {
446
468
  * to an enumerated wallpaper id. Strategy order:
447
469
  * 1. exact normalized path match against enumerated main files,
448
470
  * 2. workshop url fragment `…/431960/<projectId>/…`,
449
- * 3. trailing project-folder name (covers install-relative entries).
471
+ * 3. trailing project-folder name (covers install-relative entries),
472
+ * 4. bare workshop id.
450
473
  * @param {string} item
451
474
  * @param {Map<string, string>} byPath pathKey(fileAbs) → id
452
475
  * @param {Map<string, unknown>} byId id → project
@@ -459,6 +482,7 @@ export function resolvePlaylistItem(item, byPath, byId) {
459
482
  if (workshop && byId.has(workshop[1])) return workshop[1];
460
483
  const folder = /[\\/]([^\\/]+)[\\/][^\\/]+$/i.exec(item);
461
484
  if (folder && byId.has(folder[1])) return folder[1];
485
+ if (typeof item === 'string' && byId.has(item)) return item; // bare id
462
486
  return null;
463
487
  }
464
488
 
@@ -551,8 +575,11 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
551
575
 
552
576
  const wallpapers = [];
553
577
  for (const p of projects) {
554
- const portable = PORTABLE_KINDS.includes(p.type);
555
- 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);
556
583
  const hasPreview = Boolean(p.previewAbs) && await exists(p.previewAbs);
557
584
  const mediaToken = hasMedia
558
585
  ? mint({ abs: p.fileAbs, rootDir: p.type === 'web' ? p.dir : null, key: `${p.id}:media` })
@@ -566,6 +593,8 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
566
593
  type: p.type,
567
594
  playable: hasMedia && Boolean(mediaToken),
568
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.
569
598
  preview: hasPreview && previewToken ? `${base}/preview/${previewToken}` : null,
570
599
  });
571
600
  }
@@ -583,7 +612,11 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
583
612
  const seen = new Set();
584
613
  for (const item of playlist.items) {
585
614
  const id = resolvePlaylistItem(item, byPath, byId);
586
- 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); }
587
620
  }
588
621
  return {
589
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
 
@@ -155,14 +155,22 @@ export function createRouteRegistrar(webServer, opts = {}) {
155
155
  // ── Inventory (cached) ──────────────────────────────────────────────────
156
156
  let cache = null; // { at: number, payload: object }
157
157
  let discoveryCache = null; // { at: number, value: object }
158
+ let discovering = null; // in-flight discovery (cold-start dedupe)
158
159
  let building = null; // in-flight build promise (request coalescing)
159
160
 
160
161
  async function getDiscovery(force) {
161
162
  const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
162
163
  if (!force && fresh) return discoveryCache.value;
163
- const value = await discoverFn();
164
- discoveryCache = { at: Date.now(), value };
165
- 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;
166
174
  }
167
175
 
168
176
  async function buildInventory(force) {
@@ -180,8 +188,14 @@ export function createRouteRegistrar(webServer, opts = {}) {
180
188
  if (!force && cache && Date.now() - cache.at < inventoryTtlMs) {
181
189
  return Promise.resolve(cache.payload);
182
190
  }
183
- // Coalesce concurrent builds (double-clicked 刷新, parallel clients).
184
- 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
+ }
185
199
  building = buildInventory(force)
186
200
  .then((payload) => { cache = { at: Date.now(), payload }; return payload; })
187
201
  .finally(() => { building = null; });
@@ -207,10 +221,18 @@ export function createRouteRegistrar(webServer, opts = {}) {
207
221
  res.end();
208
222
  return;
209
223
  }
210
- res.setHeader('Content-Type', core.mimeFor(absPath));
224
+ const mime = core.mimeFor(absPath);
225
+ res.setHeader('Content-Type', mime);
211
226
  res.setHeader('Accept-Ranges', 'bytes');
212
227
  res.setHeader('Cache-Control', 'no-store');
213
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
+ }
214
236
 
215
237
  const range = core.parseRangeHeader(req.headers.range, st.size);
216
238
  if (range && range.error) {
@@ -237,6 +259,16 @@ export function createRouteRegistrar(webServer, opts = {}) {
237
259
  // leave the already-announced Content-Length unsatisfied and the
238
260
  // client hanging — destroy the response instead.
239
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
+ });
240
272
  stream.pipe(res);
241
273
  }).catch(() => {
242
274
  res.statusCode = 404;
@@ -245,6 +277,27 @@ export function createRouteRegistrar(webServer, opts = {}) {
245
277
  });
246
278
  }
247
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
+
248
301
  // ── Routes ──────────────────────────────────────────────────────────────
249
302
  const disposers = [];
250
303
 
@@ -253,14 +306,24 @@ export function createRouteRegistrar(webServer, opts = {}) {
253
306
  path: `${BASE}/inventory`,
254
307
  handler: (req, res) => {
255
308
  if (req.method !== 'GET' && req.method !== 'HEAD') {
309
+ res.setHeader('Allow', 'GET, HEAD');
256
310
  sendJson(req, res, 405, { error: 'method not allowed' });
257
311
  return;
258
312
  }
259
- 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
+ }
260
320
  getInventory(url.searchParams.get('refresh') === '1')
261
321
  .then((payload) => sendJson(req, res, 200, payload))
262
322
  .catch((err) => {
263
- 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' });
264
327
  });
265
328
  },
266
329
  }));
@@ -272,6 +335,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
272
335
  path: `${BASE}/${seg}`,
273
336
  handler: (req, res) => {
274
337
  if (req.method !== 'GET' && req.method !== 'HEAD') {
338
+ res.setHeader('Allow', 'GET, HEAD');
275
339
  sendJson(req, res, 405, { error: 'method not allowed' });
276
340
  return;
277
341
  }
@@ -324,7 +388,17 @@ export function createRouteRegistrar(webServer, opts = {}) {
324
388
  return;
325
389
  }
326
390
  }
327
- 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
+ });
328
402
  },
329
403
  }));
330
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.7",
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",