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 +6 -6
- package/README.zh.md +6 -6
- package/lib/client.js +274 -79
- package/lib/core.js +39 -6
- package/lib/index.js +84 -10
- package/lib/types/index.d.ts +5 -1
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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,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
|
-
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
//
|
|
595
|
-
//
|
|
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
|
-
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
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
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
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 -
|
|
687
|
-
|
|
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
|
-
|
|
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
|
|
839
|
-
//
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1259
|
-
|
|
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("
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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.
|
|
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:
|
|
1529
|
+
object-position: center;
|
|
1365
1530
|
display: block;
|
|
1366
1531
|
background: transparent; border: 0;
|
|
1367
|
-
filter:
|
|
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).
|
|
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
|
|
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
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
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;
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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",
|