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/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()]) {
@@ -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
- discovering = discoverFn()
184
+ const run = discoverFn()
168
185
  .then((value) => {
169
186
  discoveryCache = { at: Date.now(), value };
170
187
  return value;
171
188
  })
172
- .finally(() => { if (!force) discovering = null; });
173
- return discovering;
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.statusCode = 404;
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.setHeader('Content-Length', '0');
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.statusCode = 404;
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.statusCode = 404;
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
- // 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;
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.statusCode = 404;
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
  };
@@ -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.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",