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