wallpaper-engine-dsh 0.1.0 → 0.1.2

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
@@ -272,11 +272,21 @@ export function inferType(file) {
272
272
  * @returns {boolean}
273
273
  */
274
274
  export function isInsideDir(root, target, platform = process.platform) {
275
- let r = resolve(String(root));
276
- let t = resolve(String(target));
277
- if (platform === 'win32') { r = r.toLowerCase(); t = t.toLowerCase(); }
275
+ const win = platform === 'win32';
276
+ // Canonicalize for comparison: win32 paths are case-insensitive and accept
277
+ // both separators, so normalize `/` and `\` to `\` and lowercase. This also
278
+ // makes the win32 semantics identical no matter which OS the code runs on
279
+ // (CI is Linux; the WE files themselves are Windows paths).
280
+ const canon = (p) => {
281
+ let r = resolve(String(p));
282
+ if (win) r = r.replace(/[\\/]/g, '\\').toLowerCase();
283
+ return r;
284
+ };
285
+ const r = canon(root);
286
+ const t = canon(target);
278
287
  if (t === r) return true;
279
- const withSep = r.endsWith(sep) ? r : r + sep;
288
+ const boundary = win ? '\\' : sep;
289
+ const withSep = r.endsWith(boundary) ? r : r + boundary;
280
290
  return t.startsWith(withSep);
281
291
  }
282
292
 
@@ -497,3 +507,85 @@ export function mimeFor(absPath) {
497
507
  woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',
498
508
  }[ext] || 'application/octet-stream';
499
509
  }
510
+
511
+ // ── 6. Inventory assembly (pure; the host wires it to HTTP) ──────────────────
512
+
513
+ /**
514
+ * Assemble the JSON inventory payload from a discovery result. Pure on top of
515
+ * the enumeration/playlist helpers, so the host's HTTP layer stays thin and
516
+ * this function is directly unit-testable against fixture trees.
517
+ *
518
+ * @param {{ installDir: string | null, libraryRoots: string[] }} discovery
519
+ * @param {object} [io] overridable IO: existsSync/readFileSync/readdirSync/
520
+ * statSync (default to node:fs), plus:
521
+ * - `mint(entry)` — mint a media/preview token for a file entry; the host
522
+ * provides a random-token map, tests provide a spy.
523
+ * - `base` — URL prefix for media/preview links (default '/we-background').
524
+ * @returns {{ installDir, total, portableCount, wallpapers, playlists }}
525
+ */
526
+ export function buildInventoryFrom(discovery = {}, io = {}) {
527
+ const exists = io.existsSync || existsSync;
528
+ const read = io.readFileSync || readFileSync;
529
+ const mint = io.mint || (() => null);
530
+ const base = io.base || '/we-background';
531
+
532
+ const installDir = discovery.installDir || null;
533
+ const libraryRoots = Array.isArray(discovery.libraryRoots) ? discovery.libraryRoots : [];
534
+ const roots = wallpaperRoots(installDir, libraryRoots, { existsSync: exists });
535
+ const projects = enumerateWallpapers(roots, io);
536
+ const byId = new Map(projects.map((p) => [p.id, p]));
537
+ const byPath = new Map(projects.map((p) => [pathKey(p.fileAbs), p.id]));
538
+
539
+ const wallpapers = projects.map((p) => {
540
+ const portable = PORTABLE_KINDS.includes(p.type);
541
+ const hasMedia = portable && exists(p.fileAbs);
542
+ const hasPreview = Boolean(p.previewAbs) && exists(p.previewAbs);
543
+ return {
544
+ id: p.id,
545
+ title: p.title,
546
+ type: p.type,
547
+ playable: hasMedia,
548
+ media: hasMedia
549
+ ? `${base}/media/${mint({ abs: p.fileAbs, rootDir: p.type === 'web' ? p.dir : null })}`
550
+ : null,
551
+ preview: hasPreview
552
+ ? `${base}/preview/${mint({ abs: p.previewAbs, rootDir: null })}`
553
+ : null,
554
+ };
555
+ });
556
+ const playableIds = new Set(wallpapers.filter((w) => w.playable).map((w) => w.id));
557
+
558
+ let playlists = [];
559
+ const configPath = installDir ? join(installDir, 'config.json') : null;
560
+ if (configPath && exists(configPath)) {
561
+ try {
562
+ playlists = parsePlaylists(JSON.parse(read(configPath, 'utf8')));
563
+ } catch { /* malformed config.json → no playlists, not fatal */ }
564
+ }
565
+ const playlistView = playlists.map((playlist, index) => {
566
+ const ids = [];
567
+ const seen = new Set();
568
+ for (const item of playlist.items) {
569
+ const id = resolvePlaylistItem(item, byPath, byId);
570
+ if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
571
+ }
572
+ return {
573
+ id: `pl-${index}`,
574
+ name: playlist.name,
575
+ order: playlist.order,
576
+ delay: playlist.delay,
577
+ wallpaperIds: ids,
578
+ total: ids.length,
579
+ portableCount: ids.filter((id) => playableIds.has(id)).length,
580
+ unresolvedCount: Math.max(0, playlist.items.length - ids.length),
581
+ };
582
+ });
583
+
584
+ return {
585
+ installDir,
586
+ total: wallpapers.length,
587
+ portableCount: wallpapers.filter((w) => w.playable).length,
588
+ wallpapers,
589
+ playlists: playlistView,
590
+ };
591
+ }
package/lib/index.js CHANGED
@@ -47,8 +47,6 @@ import * as core from './core.js';
47
47
  const BASE = '/we-background';
48
48
  /** Inventory is cached and rebuilt at most this often unless ?refresh=1. */
49
49
  const INVENTORY_TTL_MS = 30_000;
50
- /** Media kinds eligible for sub-asset serving (web wallpapers bundle files). */
51
- const SUBASSET_KINDS = new Set(['web']);
52
50
 
53
51
  /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
54
52
  function steamRegistryRoot() {
@@ -107,74 +105,24 @@ export function apply(ctx) {
107
105
  // ── Inventory (cached) ──────────────────────────────────────────────────
108
106
  let cache = null; // { at: number, payload: object }
109
107
 
110
- function buildInventory() {
111
- tokens.clear(); // re-mint: tokens from older builds stop resolving
112
- const { installDir, libraryRoots } = discover();
113
- const roots = core.wallpaperRoots(installDir, libraryRoots);
114
- const projects = core.enumerateWallpapers(roots);
115
- const byId = new Map(projects.map((p) => [p.id, p]));
116
- const byPath = new Map(projects.map((p) => [core.pathKey(p.fileAbs), p.id]));
117
-
118
- const wallpapers = projects.map((p) => {
119
- const portable = core.PORTABLE_KINDS.includes(p.type);
120
- const hasMedia = portable && existsSync(p.fileAbs);
121
- const hasPreview = Boolean(p.previewAbs) && existsSync(p.previewAbs);
122
- return {
123
- id: p.id,
124
- title: p.title,
125
- type: p.type,
126
- playable: hasMedia,
127
- media: hasMedia
128
- ? `${BASE}/media/${mint({
129
- abs: p.fileAbs,
130
- rootDir: SUBASSET_KINDS.has(p.type) ? p.dir : null,
131
- })}`
132
- : null,
133
- preview: hasPreview
134
- ? `${BASE}/preview/${mint({ abs: p.previewAbs, rootDir: null })}`
135
- : null,
136
- };
137
- });
138
- const playableIds = new Set(wallpapers.filter((w) => w.playable).map((w) => w.id));
139
-
140
- let playlists = [];
141
- const configPath = installDir ? join(installDir, 'config.json') : null;
142
- if (configPath && existsSync(configPath)) {
143
- try {
144
- playlists = core.parsePlaylists(JSON.parse(readFileSync(configPath, 'utf8')));
145
- } catch { /* malformed config.json → no playlists, not fatal */ }
146
- }
147
- const playlistView = playlists.map((playlist, index) => {
148
- const ids = [];
149
- const seen = new Set();
150
- for (const item of playlist.items) {
151
- const id = core.resolvePlaylistItem(item, byPath, byId);
152
- if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
153
- }
154
- return {
155
- id: `pl-${index}`,
156
- name: playlist.name,
157
- order: playlist.order,
158
- delay: playlist.delay,
159
- wallpaperIds: ids,
160
- total: ids.length,
161
- portableCount: ids.filter((id) => playableIds.has(id)).length,
162
- unresolvedCount: Math.max(0, playlist.items.length - ids.length),
163
- };
164
- });
108
+ // Discovery is expensive (reg.exe + vdf scan) and the install layout barely
109
+ // changes — resolve it ONCE and reuse across builds; only an explicit
110
+ // refresh (or TTL expiry on the first build) forces a re-discovery.
111
+ let discoveryCache = null;
112
+ function getDiscovery(force) {
113
+ if (!force && discoveryCache) return discoveryCache;
114
+ discoveryCache = discover();
115
+ return discoveryCache;
116
+ }
165
117
 
166
- return {
167
- installDir,
168
- total: wallpapers.length,
169
- portableCount: wallpapers.filter((w) => w.playable).length,
170
- wallpapers,
171
- playlists: playlistView,
172
- };
118
+ function buildInventory(force) {
119
+ tokens.clear(); // re-mint: tokens from older builds stop resolving
120
+ return core.buildInventoryFrom(getDiscovery(force), { mint });
173
121
  }
174
122
 
175
123
  function getInventory(force) {
176
124
  if (!force && cache && Date.now() - cache.at < INVENTORY_TTL_MS) return cache.payload;
177
- const payload = buildInventory();
125
+ const payload = buildInventory(force);
178
126
  cache = { at: Date.now(), payload };
179
127
  return payload;
180
128
  }
@@ -235,10 +183,18 @@ export function apply(ctx) {
235
183
  kind: 'exact',
236
184
  path: `${BASE}/inventory`,
237
185
  handler: (req, res) => {
238
- if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return; }
186
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
187
+ sendJson(res, 405, { error: 'method not allowed' });
188
+ return;
189
+ }
239
190
  try {
240
191
  const url = new URL(req.url || '/', 'http://x');
241
- sendJson(res, 200, getInventory(url.searchParams.get('refresh') === '1'));
192
+ const payload = JSON.stringify(getInventory(url.searchParams.get('refresh') === '1'));
193
+ res.statusCode = 200;
194
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
195
+ res.setHeader('Cache-Control', 'no-store');
196
+ res.setHeader('Content-Length', String(Buffer.byteLength(payload)));
197
+ res.end(req.method === 'HEAD' ? undefined : payload);
242
198
  } catch (err) {
243
199
  sendJson(res, 500, { error: String(err && err.message ? err.message : err) });
244
200
  }
@@ -273,7 +229,8 @@ export function apply(ctx) {
273
229
  const entry = tokens.get(token);
274
230
  if (!entry) {
275
231
  res.statusCode = 404;
276
- res.end('not found');
232
+ if (req.method === 'HEAD') res.end();
233
+ else res.end('not found');
277
234
  return;
278
235
  }
279
236
  let abs = entry.abs;
@@ -281,7 +238,8 @@ export function apply(ctx) {
281
238
  // Sub-asset of a bundled web wallpaper: must stay inside its dir.
282
239
  if (seg !== 'media' || !entry.rootDir) {
283
240
  res.statusCode = 404;
284
- res.end('not found');
241
+ if (req.method === 'HEAD') res.end();
242
+ else res.end('not found');
285
243
  return;
286
244
  }
287
245
  let rel;
@@ -304,11 +262,16 @@ export function apply(ctx) {
304
262
  }));
305
263
  }
306
264
 
265
+ // Warm the discovery cache now (reg.exe + vdf scan) so the first inventory
266
+ // request answers immediately instead of paying the discovery cost inline.
267
+ try { getDiscovery(false); } catch { /* non-fatal: inventory will 500 with reason */ }
268
+
307
269
  return () => {
308
270
  for (const d of disposers) { try { d(); } catch { /* ignore */ } }
309
271
  disposers.length = 0;
310
272
  tokens.clear();
311
273
  cache = null;
274
+ discoveryCache = null;
312
275
  };
313
276
  }
314
277
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Wallpaper Engine Video/Web wallpapers as the DSH web GUI background — discovery, sandboxed rendering, rotation lists, liquid-glass tuning.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -61,7 +61,10 @@
61
61
  "verify": "node scripts/verify-client.mjs",
62
62
  "test": "node --test test/core.test.mjs",
63
63
  "prepare": "node scripts/prepare.mjs",
64
- "prepublishOnly": "npm run test && npm run build && npm run verify"
64
+ "prepublishOnly": "npm run test && npm run build && npm run verify",
65
+ "release": "npm version patch -m \"chore: release v%s\" && git push --follow-tags",
66
+ "release:minor": "npm version minor -m \"chore: release v%s\" && git push --follow-tags",
67
+ "release:major": "npm version major -m \"chore: release v%s\" && git push --follow-tags"
65
68
  },
66
69
  "engines": {
67
70
  "node": ">=18.17"
@@ -74,8 +77,20 @@
74
77
  "react": "^18.2.0"
75
78
  },
76
79
  "peerDependenciesMeta": {
80
+ "@deepseek-ai/cordis": {
81
+ "optional": true
82
+ },
83
+ "@deepseek-ai/dsh-client-runtime": {
84
+ "optional": true
85
+ },
86
+ "@deepseek-ai/dsh-client-ui-slots": {
87
+ "optional": true
88
+ },
77
89
  "@deepseek-ai/dsh-host-webserver": {
78
90
  "optional": true
91
+ },
92
+ "react": {
93
+ "optional": true
79
94
  }
80
95
  }
81
96
  }