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 +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +73 -101
- package/lib/core.js +39 -70
- package/lib/index.js +84 -19
- package/lib/types/index.d.ts +5 -1
- package/package.json +1 -1
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
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
|
-
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
|
|
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
|
|
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
|
|
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 -
|
|
731
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1428
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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: '
|
|
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.
|
|
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",
|