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/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.filter((id) => playableIds.has(id)).length,
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 = url.slice(url.lastIndexOf('/') + 1);
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.statusCode = 404;
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.setHeader('Content-Length', '0');
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.statusCode = 404;
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.statusCode = 404;
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
- // Backslash parts are never legitimate in a URL path here; on win32
380
- // `..\evil` would slip past a `/`-only split (the isInsideDir
381
- // backstop still catches it — this is defense in depth).
382
- if (!rel || rel.split('/').some((part) => part === '..' || part === '' || part.includes('\\'))) {
383
- sendJson(req, res, 403, { error: 'forbidden' });
384
- return;
385
- }
386
- abs = join(entry.rootDir, ...rel.split('/'));
387
- if (!core.isInsideDir(entry.rootDir, abs)) {
388
- sendJson(req, res, 403, { error: 'forbidden' });
389
- return;
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.statusCode = 404;
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
  };
@@ -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.2",
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",