wallpaper-engine-dsh 1.0.0 → 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 +527 -428
- package/lib/core.js +27 -3
- package/lib/index.js +78 -40
- 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,20 +254,25 @@ 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
|
-
//
|
|
232
|
-
// never execute against the DSH origin.
|
|
233
|
-
//
|
|
234
|
-
|
|
265
|
+
// sandbox CSP even on direct top-level navigation, so they can
|
|
266
|
+
// never execute against the DSH origin. `allow-scripts` keeps the
|
|
267
|
+
// wallpaper itself functional — inside the sandbox the origin is
|
|
268
|
+
// opaque either way, so the isolation goal is unchanged.
|
|
269
|
+
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts');
|
|
235
270
|
}
|
|
236
271
|
|
|
237
272
|
const range = core.parseRangeHeader(req.headers.range, st.size);
|
|
238
273
|
if (range && range.error) {
|
|
239
|
-
res.statusCode = 416;
|
|
240
274
|
res.setHeader('Content-Range', `bytes */${st.size}`);
|
|
241
|
-
res
|
|
242
|
-
res.end();
|
|
275
|
+
sendEmpty(res, 416);
|
|
243
276
|
return;
|
|
244
277
|
}
|
|
245
278
|
const head = req.method === 'HEAD';
|
|
@@ -271,9 +304,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
271
304
|
});
|
|
272
305
|
stream.pipe(res);
|
|
273
306
|
}).catch(() => {
|
|
274
|
-
res
|
|
275
|
-
res.setHeader('Content-Length', '0');
|
|
276
|
-
res.end();
|
|
307
|
+
sendEmpty(res, 404);
|
|
277
308
|
});
|
|
278
309
|
}
|
|
279
310
|
|
|
@@ -334,8 +365,9 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
334
365
|
kind: 'prefix',
|
|
335
366
|
path: `${BASE}/${seg}`,
|
|
336
367
|
handler: (req, res) => {
|
|
368
|
+
if (req.method === 'OPTIONS') { sendCorsPreflight(res); return; }
|
|
337
369
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
338
|
-
res.setHeader('Allow', 'GET, HEAD');
|
|
370
|
+
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
|
|
339
371
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
340
372
|
return;
|
|
341
373
|
}
|
|
@@ -356,45 +388,50 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
356
388
|
}
|
|
357
389
|
const entry = tokens.get(token);
|
|
358
390
|
if (!entry) {
|
|
359
|
-
res
|
|
360
|
-
res.setHeader('Content-Length', '0');
|
|
361
|
-
res.end();
|
|
391
|
+
sendEmpty(res, 404);
|
|
362
392
|
return;
|
|
363
393
|
}
|
|
364
394
|
let abs = entry.abs;
|
|
365
395
|
if (slash >= 0) {
|
|
366
|
-
// Sub-asset of a bundled web wallpaper: must stay inside its dir.
|
|
367
|
-
if (seg !== 'media' || !entry.rootDir) {
|
|
368
|
-
res.statusCode = 404;
|
|
369
|
-
res.setHeader('Content-Length', '0');
|
|
370
|
-
res.end();
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
396
|
let rel;
|
|
374
397
|
try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
|
|
375
398
|
sendJson(req, res, 400, { error: 'bad path' });
|
|
376
399
|
return;
|
|
377
400
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
+
}
|
|
389
428
|
}
|
|
390
429
|
}
|
|
391
430
|
// Symlink backstop: serve only what realpath still keeps inside the
|
|
392
431
|
// entry's real root (404 — indistinguishable from a vanished file).
|
|
393
432
|
realPathWithin(entry, abs).then((real) => {
|
|
394
433
|
if (!real) {
|
|
395
|
-
res
|
|
396
|
-
res.setHeader('Content-Length', '0');
|
|
397
|
-
res.end();
|
|
434
|
+
sendEmpty(res, 404);
|
|
398
435
|
return;
|
|
399
436
|
}
|
|
400
437
|
serveFile(real, req, res);
|
|
@@ -411,6 +448,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
411
448
|
for (const d of disposers) { try { d(); } catch { /* ignore */ } }
|
|
412
449
|
disposers.length = 0;
|
|
413
450
|
remintAll();
|
|
451
|
+
realRootCache.clear();
|
|
414
452
|
cache = null;
|
|
415
453
|
discoveryCache = null;
|
|
416
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",
|