wallpaper-engine-dsh 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/README.zh.md +13 -13
- package/lib/client.js +542 -438
- package/lib/core.js +27 -3
- package/lib/index.js +82 -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()]) {
|
|
@@ -162,15 +175,20 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
162
175
|
const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
|
|
163
176
|
if (!force && fresh) return discoveryCache.value;
|
|
164
177
|
// Concurrent callers (registration warm-up + first request) share ONE
|
|
165
|
-
// discovery run instead of racing reg.exe twice at cold start.
|
|
178
|
+
// discovery run instead of racing reg.exe twice at cold start. A forced
|
|
179
|
+
// run deliberately does not join an in-flight non-forced one; either way,
|
|
180
|
+
// a run only releases the slot it still owns — clearing it unconditionally
|
|
181
|
+
// would kill another run's join promise, never clearing it would pin the
|
|
182
|
+
// slot to a settled promise and freeze TTL re-discovery forever.
|
|
166
183
|
if (discovering && !force) return discovering;
|
|
167
|
-
|
|
184
|
+
const run = discoverFn()
|
|
168
185
|
.then((value) => {
|
|
169
186
|
discoveryCache = { at: Date.now(), value };
|
|
170
187
|
return value;
|
|
171
188
|
})
|
|
172
|
-
.finally(() => { if (
|
|
173
|
-
|
|
189
|
+
.finally(() => { if (discovering === run) discovering = null; });
|
|
190
|
+
discovering = run;
|
|
191
|
+
return run;
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
async function buildInventory(force) {
|
|
@@ -212,13 +230,28 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
212
230
|
res.end(req.method === 'HEAD' ? undefined : text);
|
|
213
231
|
}
|
|
214
232
|
|
|
233
|
+
/** Bodyless status answer (404/405-style); indistinguishable per reason. */
|
|
234
|
+
function sendEmpty(res, status) {
|
|
235
|
+
res.statusCode = status;
|
|
236
|
+
res.setHeader('Content-Length', '0');
|
|
237
|
+
res.end();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Preflight answer for the media/preview surface (see serveFile re CORS). */
|
|
241
|
+
function sendCorsPreflight(res) {
|
|
242
|
+
res.statusCode = 204;
|
|
243
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
244
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
245
|
+
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
246
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
247
|
+
res.end();
|
|
248
|
+
}
|
|
249
|
+
|
|
215
250
|
/** Stream one file with Range support (video seeking) and HEAD handling. */
|
|
216
251
|
function serveFile(absPath, req, res) {
|
|
217
252
|
fsp.stat(absPath).then((st) => {
|
|
218
253
|
if (!st.isFile()) {
|
|
219
|
-
res
|
|
220
|
-
res.setHeader('Content-Length', '0');
|
|
221
|
-
res.end();
|
|
254
|
+
sendEmpty(res, 404);
|
|
222
255
|
return;
|
|
223
256
|
}
|
|
224
257
|
const mime = core.mimeFor(absPath);
|
|
@@ -226,6 +259,12 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
226
259
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
227
260
|
res.setHeader('Cache-Control', 'no-store');
|
|
228
261
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
262
|
+
// Web wallpapers render in a sandboxed iframe (opaque origin), so their
|
|
263
|
+
// own fetch()/XHR for sub-assets is CROSS-origin from the browser's
|
|
264
|
+
// point of view and would be blocked without an explicit allow. The
|
|
265
|
+
// unguessable token remains the only capability — wildcard origin
|
|
266
|
+
// grants nothing beyond what a known URL already grants.
|
|
267
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
229
268
|
if (mime === 'text/html' || mime === 'image/svg+xml') {
|
|
230
269
|
// Script-capable documents (web wallpapers, SVG previews) get a
|
|
231
270
|
// sandbox CSP even on direct top-level navigation, so they can
|
|
@@ -237,10 +276,8 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
237
276
|
|
|
238
277
|
const range = core.parseRangeHeader(req.headers.range, st.size);
|
|
239
278
|
if (range && range.error) {
|
|
240
|
-
res.statusCode = 416;
|
|
241
279
|
res.setHeader('Content-Range', `bytes */${st.size}`);
|
|
242
|
-
res
|
|
243
|
-
res.end();
|
|
280
|
+
sendEmpty(res, 416);
|
|
244
281
|
return;
|
|
245
282
|
}
|
|
246
283
|
const head = req.method === 'HEAD';
|
|
@@ -272,9 +309,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
272
309
|
});
|
|
273
310
|
stream.pipe(res);
|
|
274
311
|
}).catch(() => {
|
|
275
|
-
res
|
|
276
|
-
res.setHeader('Content-Length', '0');
|
|
277
|
-
res.end();
|
|
312
|
+
sendEmpty(res, 404);
|
|
278
313
|
});
|
|
279
314
|
}
|
|
280
315
|
|
|
@@ -335,8 +370,9 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
335
370
|
kind: 'prefix',
|
|
336
371
|
path: `${BASE}/${seg}`,
|
|
337
372
|
handler: (req, res) => {
|
|
373
|
+
if (req.method === 'OPTIONS') { sendCorsPreflight(res); return; }
|
|
338
374
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
339
|
-
res.setHeader('Allow', 'GET, HEAD');
|
|
375
|
+
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
|
|
340
376
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
341
377
|
return;
|
|
342
378
|
}
|
|
@@ -357,45 +393,50 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
357
393
|
}
|
|
358
394
|
const entry = tokens.get(token);
|
|
359
395
|
if (!entry) {
|
|
360
|
-
res
|
|
361
|
-
res.setHeader('Content-Length', '0');
|
|
362
|
-
res.end();
|
|
396
|
+
sendEmpty(res, 404);
|
|
363
397
|
return;
|
|
364
398
|
}
|
|
365
399
|
let abs = entry.abs;
|
|
366
400
|
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
401
|
let rel;
|
|
375
402
|
try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
|
|
376
403
|
sendJson(req, res, 400, { error: 'bad path' });
|
|
377
404
|
return;
|
|
378
405
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
406
|
+
if (rel === '') {
|
|
407
|
+
// Directory form (`…/media/<token>/`): the entry document itself,
|
|
408
|
+
// exactly like the bare-token URL — abs already points at it, so
|
|
409
|
+
// fall through to the shared symlink backstop below. Preview
|
|
410
|
+
// tokens have no directory form.
|
|
411
|
+
if (seg !== 'media') {
|
|
412
|
+
sendEmpty(res, 404);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// Sub-asset of a bundled web wallpaper: must stay inside its dir.
|
|
417
|
+
if (seg !== 'media' || !entry.rootDir) {
|
|
418
|
+
sendEmpty(res, 404);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Backslash parts are never legitimate in a URL path here; on
|
|
422
|
+
// win32 `..\evil` would slip past a `/`-only split (the
|
|
423
|
+
// isInsideDir backstop still catches it — defense in depth).
|
|
424
|
+
if (rel.split('/').some((part) => part === '..' || part === '' || part.includes('\\'))) {
|
|
425
|
+
sendJson(req, res, 403, { error: 'forbidden' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
abs = join(entry.rootDir, ...rel.split('/'));
|
|
429
|
+
if (!core.isInsideDir(entry.rootDir, abs)) {
|
|
430
|
+
sendJson(req, res, 403, { error: 'forbidden' });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
390
433
|
}
|
|
391
434
|
}
|
|
392
435
|
// Symlink backstop: serve only what realpath still keeps inside the
|
|
393
436
|
// entry's real root (404 — indistinguishable from a vanished file).
|
|
394
437
|
realPathWithin(entry, abs).then((real) => {
|
|
395
438
|
if (!real) {
|
|
396
|
-
res
|
|
397
|
-
res.setHeader('Content-Length', '0');
|
|
398
|
-
res.end();
|
|
439
|
+
sendEmpty(res, 404);
|
|
399
440
|
return;
|
|
400
441
|
}
|
|
401
442
|
serveFile(real, req, res);
|
|
@@ -412,6 +453,7 @@ export function createRouteRegistrar(webServer, opts = {}) {
|
|
|
412
453
|
for (const d of disposers) { try { d(); } catch { /* ignore */ } }
|
|
413
454
|
disposers.length = 0;
|
|
414
455
|
remintAll();
|
|
456
|
+
realRootCache.clear();
|
|
415
457
|
cache = null;
|
|
416
458
|
discoveryCache = null;
|
|
417
459
|
};
|
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.4",
|
|
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",
|