wallpaper-engine-dsh 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +498 -420
- package/lib/core.js +27 -3
- package/lib/index.js +73 -36
- package/lib/types/index.d.ts +23 -0
- package/package.json +1 -1
package/lib/core.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
-
import { join, resolve, normalize, basename, sep } from 'node:path';
|
|
20
|
+
import { join, resolve, normalize, relative, basename, sep } from 'node:path';
|
|
21
21
|
|
|
22
22
|
/** Steam appid for Wallpaper Engine. */
|
|
23
23
|
export const WE_APPID = '431960';
|
|
@@ -542,6 +542,18 @@ export function mimeFor(absPath) {
|
|
|
542
542
|
}[ext] || 'application/octet-stream';
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
/**
|
|
546
|
+
* A filesystem-relative path (`bin\index.html`, `我的 壁纸.html`) as URL path
|
|
547
|
+
* segments (`bin/index.html`, `%E6%88%91…`). Each segment is encoded
|
|
548
|
+
* separately so `/` stays a separator and spaces/unicode survive the round
|
|
549
|
+
* trip through the route's decodeURIComponent.
|
|
550
|
+
* @param {string} fsPath
|
|
551
|
+
* @returns {string}
|
|
552
|
+
*/
|
|
553
|
+
export function toUrlPath(fsPath) {
|
|
554
|
+
return String(fsPath).split(/[\\/]/).filter(Boolean).map(encodeURIComponent).join('/');
|
|
555
|
+
}
|
|
556
|
+
|
|
545
557
|
// ── 6. Inventory assembly (pure; the host wires it to HTTP) ──────────────────
|
|
546
558
|
|
|
547
559
|
/**
|
|
@@ -587,12 +599,22 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
|
|
|
587
599
|
const previewToken = hasPreview
|
|
588
600
|
? mint({ abs: p.previewAbs, rootDir: null, key: `${p.id}:preview` })
|
|
589
601
|
: null;
|
|
602
|
+
// Web wallpapers are addressed AT their entry file (`…/media/<token>/index.html`):
|
|
603
|
+
// the document URL then mirrors the on-disk layout of the project, so the
|
|
604
|
+
// wallpaper's own relative references (scripts, styles, images, fetches)
|
|
605
|
+
// resolve exactly where Wallpaper Engine itself would resolve them. A bare
|
|
606
|
+
// token URL would strand `app.js` at `…/media/app.js` — one directory too
|
|
607
|
+
// high, a 404, and a blank wallpaper. Videos keep the bare form: browsers
|
|
608
|
+
// never resolve relative URLs against a <video> src.
|
|
609
|
+
const mediaSuffix = p.type === 'web' && mediaToken
|
|
610
|
+
? '/' + toUrlPath(relative(p.dir, p.fileAbs))
|
|
611
|
+
: '';
|
|
590
612
|
wallpapers.push({
|
|
591
613
|
id: p.id,
|
|
592
614
|
title: p.title,
|
|
593
615
|
type: p.type,
|
|
594
616
|
playable: hasMedia && Boolean(mediaToken),
|
|
595
|
-
media: hasMedia && mediaToken ? `${base}/media/${mediaToken}` : null,
|
|
617
|
+
media: hasMedia && mediaToken ? `${base}/media/${mediaToken}${mediaSuffix}` : null,
|
|
596
618
|
// The preview stays in the payload even though stills are never
|
|
597
619
|
// selected: it is the decode-failure fallback for playable entries.
|
|
598
620
|
preview: hasPreview && previewToken ? `${base}/preview/${previewToken}` : null,
|
|
@@ -624,8 +646,10 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
|
|
|
624
646
|
order: playlist.order,
|
|
625
647
|
delay: playlist.delay,
|
|
626
648
|
wallpaperIds: ids,
|
|
649
|
+
// ids only ever contains playable entries (the filter above), so
|
|
650
|
+
// every resolved item is portable by construction.
|
|
627
651
|
total: ids.length,
|
|
628
|
-
portableCount: ids.
|
|
652
|
+
portableCount: ids.length,
|
|
629
653
|
unresolvedCount: Math.max(0, playlist.items.length - ids.length),
|
|
630
654
|
};
|
|
631
655
|
});
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
* - The browser half renders web wallpapers in a sandboxed iframe
|
|
28
28
|
* (`sandbox="allow-scripts"`, no allow-same-origin), so third-party
|
|
29
29
|
* wallpaper JS gets an opaque origin and cannot touch DSH storage/APIs.
|
|
30
|
+
* That opaque origin also makes the wallpaper's own fetch()/XHR for its
|
|
31
|
+
* sub-assets cross-origin, so media/preview responses carry
|
|
32
|
+
* `Access-Control-Allow-Origin: *` — the unguessable token stays the
|
|
33
|
+
* only capability, and the surface is loopback-only.
|
|
30
34
|
* - Everything is loopback same-origin; `X-Content-Type-Options: nosniff`
|
|
31
35
|
* on every media response.
|
|
32
36
|
*
|
|
@@ -132,13 +136,22 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
132
136
|
return token;
|
|
133
137
|
};
|
|
134
138
|
const remintAll = () => { tokens.clear(); tokenByKey.clear(); };
|
|
139
|
+
// Web-wallpaper media URLs carry the entry file's path after the token
|
|
140
|
+
// (`…/media/<token>/index.html`) — the token is the FIRST path segment,
|
|
141
|
+
// never the last. Extract it structurally so pruning cannot mistake
|
|
142
|
+
// `index.html` for the token and kill a live wallpaper on a TTL rebuild.
|
|
143
|
+
const tokenUrlRe = new RegExp(`^${BASE}/(media|preview)/([^/?#]+)`);
|
|
144
|
+
const tokenOfUrl = (url) => {
|
|
145
|
+
const m = tokenUrlRe.exec(String(url));
|
|
146
|
+
return m ? m[2] : null;
|
|
147
|
+
};
|
|
135
148
|
const pruneTokens = (payload) => {
|
|
136
149
|
const live = new Set();
|
|
137
150
|
for (const w of payload.wallpapers) {
|
|
138
151
|
for (const url of [w.media, w.preview]) {
|
|
139
152
|
if (!url) continue;
|
|
140
|
-
const token =
|
|
141
|
-
live.add(token);
|
|
153
|
+
const token = tokenOfUrl(url);
|
|
154
|
+
if (token) live.add(token);
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
for (const token of [...tokens.keys()]) {
|
|
@@ -212,13 +225,28 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
212
225
|
res.end(req.method === 'HEAD' ? undefined : text);
|
|
213
226
|
}
|
|
214
227
|
|
|
228
|
+
/** Bodyless status answer (404/405-style); indistinguishable per reason. */
|
|
229
|
+
function sendEmpty(res, status) {
|
|
230
|
+
res.statusCode = status;
|
|
231
|
+
res.setHeader('Content-Length', '0');
|
|
232
|
+
res.end();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Preflight answer for the media/preview surface (see serveFile re CORS). */
|
|
236
|
+
function sendCorsPreflight(res) {
|
|
237
|
+
res.statusCode = 204;
|
|
238
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
239
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
240
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
241
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
242
|
+
res.end();
|
|
243
|
+
}
|
|
244
|
+
|
|
215
245
|
/** Stream one file with Range support (video seeking) and HEAD handling. */
|
|
216
246
|
function serveFile(absPath, req, res) {
|
|
217
247
|
fsp.stat(absPath).then((st) => {
|
|
218
248
|
if (!st.isFile()) {
|
|
219
|
-
res
|
|
220
|
-
res.setHeader('Content-Length', '0');
|
|
221
|
-
res.end();
|
|
249
|
+
sendEmpty(res, 404);
|
|
222
250
|
return;
|
|
223
251
|
}
|
|
224
252
|
const mime = core.mimeFor(absPath);
|
|
@@ -226,6 +254,12 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
226
254
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
227
255
|
res.setHeader('Cache-Control', 'no-store');
|
|
228
256
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
257
|
+
// Web wallpapers render in a sandboxed iframe (opaque origin), so their
|
|
258
|
+
// own fetch()/XHR for sub-assets is CROSS-origin from the browser's
|
|
259
|
+
// point of view and would be blocked without an explicit allow. The
|
|
260
|
+
// unguessable token remains the only capability — wildcard origin
|
|
261
|
+
// grants nothing beyond what a known URL already grants.
|
|
262
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
229
263
|
if (mime === 'text/html' || mime === 'image/svg+xml') {
|
|
230
264
|
// Script-capable documents (web wallpapers, SVG previews) get a
|
|
231
265
|
// sandbox CSP even on direct top-level navigation, so they can
|
|
@@ -237,10 +271,8 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
237
271
|
|
|
238
272
|
const range = core.parseRangeHeader(req.headers.range, st.size);
|
|
239
273
|
if (range && range.error) {
|
|
240
|
-
res.statusCode = 416;
|
|
241
274
|
res.setHeader('Content-Range', `bytes */${st.size}`);
|
|
242
|
-
res
|
|
243
|
-
res.end();
|
|
275
|
+
sendEmpty(res, 416);
|
|
244
276
|
return;
|
|
245
277
|
}
|
|
246
278
|
const head = req.method === 'HEAD';
|
|
@@ -272,9 +304,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
272
304
|
});
|
|
273
305
|
stream.pipe(res);
|
|
274
306
|
}).catch(() => {
|
|
275
|
-
res
|
|
276
|
-
res.setHeader('Content-Length', '0');
|
|
277
|
-
res.end();
|
|
307
|
+
sendEmpty(res, 404);
|
|
278
308
|
});
|
|
279
309
|
}
|
|
280
310
|
|
|
@@ -335,8 +365,9 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
335
365
|
kind: 'prefix',
|
|
336
366
|
path: `${BASE}/${seg}`,
|
|
337
367
|
handler: (req, res) => {
|
|
368
|
+
if (req.method === 'OPTIONS') { sendCorsPreflight(res); return; }
|
|
338
369
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
339
|
-
res.setHeader('Allow', 'GET, HEAD');
|
|
370
|
+
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
|
|
340
371
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
341
372
|
return;
|
|
342
373
|
}
|
|
@@ -357,45 +388,50 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
357
388
|
}
|
|
358
389
|
const entry = tokens.get(token);
|
|
359
390
|
if (!entry) {
|
|
360
|
-
res
|
|
361
|
-
res.setHeader('Content-Length', '0');
|
|
362
|
-
res.end();
|
|
391
|
+
sendEmpty(res, 404);
|
|
363
392
|
return;
|
|
364
393
|
}
|
|
365
394
|
let abs = entry.abs;
|
|
366
395
|
if (slash >= 0) {
|
|
367
|
-
// Sub-asset of a bundled web wallpaper: must stay inside its dir.
|
|
368
|
-
if (seg !== 'media' || !entry.rootDir) {
|
|
369
|
-
res.statusCode = 404;
|
|
370
|
-
res.setHeader('Content-Length', '0');
|
|
371
|
-
res.end();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
396
|
let rel;
|
|
375
397
|
try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
|
|
376
398
|
sendJson(req, res, 400, { error: 'bad path' });
|
|
377
399
|
return;
|
|
378
400
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
401
|
+
if (rel === '') {
|
|
402
|
+
// Directory form (`…/media/<token>/`): the entry document itself,
|
|
403
|
+
// exactly like the bare-token URL — abs already points at it, so
|
|
404
|
+
// fall through to the shared symlink backstop below. Preview
|
|
405
|
+
// tokens have no directory form.
|
|
406
|
+
if (seg !== 'media') {
|
|
407
|
+
sendEmpty(res, 404);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// Sub-asset of a bundled web wallpaper: must stay inside its dir.
|
|
412
|
+
if (seg !== 'media' || !entry.rootDir) {
|
|
413
|
+
sendEmpty(res, 404);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Backslash parts are never legitimate in a URL path here; on
|
|
417
|
+
// win32 `..\evil` would slip past a `/`-only split (the
|
|
418
|
+
// isInsideDir backstop still catches it — defense in depth).
|
|
419
|
+
if (rel.split('/').some((part) => part === '..' || part === '' || part.includes('\\'))) {
|
|
420
|
+
sendJson(req, res, 403, { error: 'forbidden' });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
abs = join(entry.rootDir, ...rel.split('/'));
|
|
424
|
+
if (!core.isInsideDir(entry.rootDir, abs)) {
|
|
425
|
+
sendJson(req, res, 403, { error: 'forbidden' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
390
428
|
}
|
|
391
429
|
}
|
|
392
430
|
// Symlink backstop: serve only what realpath still keeps inside the
|
|
393
431
|
// entry's real root (404 — indistinguishable from a vanished file).
|
|
394
432
|
realPathWithin(entry, abs).then((real) => {
|
|
395
433
|
if (!real) {
|
|
396
|
-
res
|
|
397
|
-
res.setHeader('Content-Length', '0');
|
|
398
|
-
res.end();
|
|
434
|
+
sendEmpty(res, 404);
|
|
399
435
|
return;
|
|
400
436
|
}
|
|
401
437
|
serveFile(real, req, res);
|
|
@@ -412,6 +448,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
412
448
|
for (const d of disposers) { try { d(); } catch { /* ignore */ } }
|
|
413
449
|
disposers.length = 0;
|
|
414
450
|
remintAll();
|
|
451
|
+
realRootCache.clear();
|
|
415
452
|
cache = null;
|
|
416
453
|
discoveryCache = null;
|
|
417
454
|
};
|
package/lib/types/index.d.ts
CHANGED
|
@@ -47,6 +47,29 @@ export interface HostContext {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** webServer-like registry consumed by createRouteRegistrar. */
|
|
51
|
+
export interface WebServerRegistry {
|
|
52
|
+
register(route: {
|
|
53
|
+
kind: 'exact' | 'prefix';
|
|
54
|
+
path: string;
|
|
55
|
+
handler(req: unknown, res: unknown): void | Promise<void>;
|
|
56
|
+
}): () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register all HTTP routes on a webServer-like registry; returns a disposer.
|
|
61
|
+
* Exported so integration tests can drive the surface against a fixture
|
|
62
|
+
* discovery without a live Steam install.
|
|
63
|
+
*/
|
|
64
|
+
export function createRouteRegistrar(
|
|
65
|
+
webServer: WebServerRegistry,
|
|
66
|
+
opts?: {
|
|
67
|
+
discover?: () => Promise<{ installDir: string | null; libraryRoots: string[] }>;
|
|
68
|
+
inventoryTtlMs?: number;
|
|
69
|
+
discoveryTtlMs?: number;
|
|
70
|
+
},
|
|
71
|
+
): () => void;
|
|
72
|
+
|
|
50
73
|
export declare const inject: string[];
|
|
51
74
|
export declare function apply(ctx: HostContext): () => void;
|
|
52
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wallpaper-engine-dsh",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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",
|