wallpaper-engine-dsh 0.1.5 → 0.1.7

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/index.js CHANGED
@@ -6,17 +6,21 @@
6
6
  * All parsing/enumeration logic lives in the pure layer (lib/core.js); this
7
7
  * file only wires it into same-origin HTTP routes on `ctx.webServer`:
8
8
  *
9
- * GET /we-background/inventory[?refresh=1] → { installDir, wallpapers, playlists }
9
+ * GET /we-background/inventory[?refresh=1] → { installDir, total,
10
+ * portableCount, wallpapers, playlists }
10
11
  * GET /we-background/media/<token>[/<sub-asset>…] → video / web HTML + assets
11
12
  * GET /we-background/preview/<token> → preview image
12
13
  *
13
14
  * Security model
14
- * - Tokens are 72-bit random values minted per inventory build (not
15
- * reversible encodings of paths). Knowing a filesystem path grants
16
- * nothing; only files enumerated into the inventory are reachable.
17
- * Rebuilding the inventory re-mints every token, so stale URLs die.
15
+ * - Tokens are 72-bit random values (not reversible encodings of paths).
16
+ * Knowing a filesystem path grants nothing; only files enumerated into
17
+ * the inventory are reachable. Tokens are stable per wallpaper across
18
+ * TTL-triggered rebuilds (so an in-flight video stream is never killed
19
+ * by a background refresh) and re-minted on an explicit ?refresh=1, so
20
+ * deliberately stale URLs die.
18
21
  * - Web-wallpaper sub-assets resolve strictly INSIDE their project
19
- * directory (core.isInsideDir) — `..` traversal is answered 403.
22
+ * directory (core.isInsideDir) — `..` and backslash traversal are
23
+ * answered 403.
20
24
  * - project.json-declared files must also be inside the project directory
21
25
  * (core.readProject), so a hostile workshop item cannot point the server
22
26
  * at arbitrary files.
@@ -26,6 +30,9 @@
26
30
  * - Everything is loopback same-origin; `X-Content-Type-Options: nosniff`
27
31
  * on every media response.
28
32
  *
33
+ * Async: discovery (reg.exe, vdf scan) and inventory builds run through
34
+ * fs/promises, so a rebuild never blocks the event loop mid-stream.
35
+ *
29
36
  * Lifecycle: every route registers through the plugin fiber and unwinds on
30
37
  * unload; the inventory cache and token map are dropped with it. The plugin
31
38
  * contributes no model-visible tool and no prompt text.
@@ -33,11 +40,9 @@
33
40
 
34
41
  import {
35
42
  createReadStream,
36
- existsSync,
37
- readFileSync,
38
- statSync,
43
+ promises as fsp,
39
44
  } from 'node:fs';
40
- import { execFileSync } from 'node:child_process';
45
+ import { execFile } from 'node:child_process';
41
46
  import { randomBytes } from 'node:crypto';
42
47
  import { join } from 'node:path';
43
48
 
@@ -47,133 +52,197 @@ import * as core from './core.js';
47
52
  const BASE = '/we-background';
48
53
  /** Inventory is cached and rebuilt at most this often unless ?refresh=1. */
49
54
  const INVENTORY_TTL_MS = 30_000;
55
+ /** Steam layout barely moves; re-discover at most this often on TTL builds. */
56
+ const DISCOVERY_TTL_MS = 10 * 60_000;
57
+
58
+ /** fs/promises-backed io for the pure layer (awaited there). */
59
+ const asyncIo = {
60
+ existsSync: async (p) => { try { await fsp.access(p); return true; } catch { return false; } },
61
+ readFileSync: (p, encoding) => fsp.readFile(p, encoding),
62
+ readdirSync: (p) => fsp.readdir(p),
63
+ statSync: (p) => fsp.stat(p),
64
+ };
50
65
 
51
66
  /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
52
67
  function steamRegistryRoot() {
53
- if (process.platform !== 'win32') return null;
54
- try {
68
+ if (process.platform !== 'win32') return Promise.resolve(null);
69
+ return new Promise((resolvePromise) => {
55
70
  const reg = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe');
56
- const out = execFileSync(
71
+ execFile(
57
72
  reg,
58
73
  ['query', 'HKCU\\Software\\Valve\\Steam', '/v', 'SteamPath'],
59
- { encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
74
+ { encoding: 'utf8', windowsHide: true, timeout: 5000 },
75
+ (err, stdout) => {
76
+ if (err) { resolvePromise(null); return; }
77
+ resolvePromise(core.steamPathFromRegQuery(stdout));
78
+ },
60
79
  );
61
- return core.steamPathFromRegQuery(out);
62
- } catch {
63
- return null;
64
- }
80
+ });
65
81
  }
66
82
 
67
83
  /** Full discovery pass: install dir + every library owning WE content. */
68
- function discover() {
84
+ async function discover() {
69
85
  const probes = core.steamProbeDirs();
70
- const registryRoot = steamRegistryRoot();
86
+ const registryRoot = await steamRegistryRoot();
71
87
  const searchRoots = registryRoot ? [registryRoot, ...probes] : probes;
72
88
  const vdfLibraries = [];
73
89
  for (const root of searchRoots) {
74
90
  const vdf = join(root, 'steamapps', 'libraryfolders.vdf');
75
- if (!existsSync(vdf)) continue;
91
+ if (!await asyncIo.existsSync(vdf)) continue;
76
92
  try {
77
- vdfLibraries.push(...core.librariesFromVdfText(readFileSync(vdf, 'utf8')));
93
+ vdfLibraries.push(...core.librariesFromVdfText(await asyncIo.readFileSync(vdf, 'utf8')));
78
94
  } catch { /* unreadable vdf → skip this root */ }
79
95
  }
80
- const installDir = core.findInstallDir(
96
+ const installDir = await core.findInstallDir(
81
97
  core.installDirCandidates({ registryRoot, libraryRoots: vdfLibraries }),
98
+ asyncIo,
82
99
  );
83
- const libraryRoots = core.workshopLibraryRoots(searchRoots, vdfLibraries);
100
+ const libraryRoots = await core.workshopLibraryRoots(searchRoots, vdfLibraries, asyncIo);
84
101
  return { installDir, libraryRoots };
85
102
  }
86
103
 
87
- export const inject = ['webServer'];
88
-
89
- export function apply(ctx) {
90
- const webServer = ctx.webServer;
91
- if (!webServer || typeof webServer.register !== 'function') {
92
- return () => {}; // defensive: inject guarantees the service in practice
93
- }
104
+ /**
105
+ * Register all routes on a webServer-like registry and return a disposer.
106
+ * Extracted from apply() so integration tests can drive the HTTP surface
107
+ * against a fixture discovery without a live Steam install.
108
+ *
109
+ * @param {{ register(route: { kind: 'exact'|'prefix', path: string, handler: Function }): () => void }} webServer
110
+ * @param {{ discover?: () => Promise<{ installDir: string|null, libraryRoots: string[] }>, inventoryTtlMs?: number, discoveryTtlMs?: number }} [opts]
111
+ */
112
+ export function createRouteRegistrar(webServer, opts = {}) {
113
+ const discoverFn = opts.discover || discover;
114
+ const inventoryTtlMs = opts.inventoryTtlMs ?? INVENTORY_TTL_MS;
115
+ const discoveryTtlMs = opts.discoveryTtlMs ?? DISCOVERY_TTL_MS;
94
116
 
95
117
  // ── Token registry ──────────────────────────────────────────────────────
96
- // token → { abs, rootDir|null }. rootDir is set for renderable projects so
97
- // web wallpapers can fetch their bundled sub-assets (contained, see below).
118
+ // token → { abs, rootDir|null }. rootDir is set for renderable web projects
119
+ // so their bundled sub-assets can be fetched (contained, see below).
120
+ // Tokens are keyed per wallpaper asset (`<id>:media` / `<id>:preview`):
121
+ // TTL rebuilds REUSE the token for an unchanged asset (in-flight streams
122
+ // survive), prune assets that vanished, and only an explicit ?refresh=1
123
+ // re-mints everything.
98
124
  const tokens = new Map();
125
+ const tokenByKey = new Map();
99
126
  const mint = (entry) => {
127
+ const key = entry && entry.key;
128
+ if (key && tokenByKey.has(key)) return tokenByKey.get(key);
100
129
  const token = randomBytes(9).toString('base64url'); // 72 bits, url-safe
101
130
  tokens.set(token, entry);
131
+ if (key) tokenByKey.set(key, token);
102
132
  return token;
103
133
  };
134
+ const remintAll = () => { tokens.clear(); tokenByKey.clear(); };
135
+ const pruneTokens = (payload) => {
136
+ const live = new Set();
137
+ for (const w of payload.wallpapers) {
138
+ for (const url of [w.media, w.preview]) {
139
+ if (!url) continue;
140
+ const token = url.slice(url.lastIndexOf('/') + 1);
141
+ live.add(token);
142
+ }
143
+ }
144
+ for (const token of [...tokens.keys()]) {
145
+ if (!live.has(token)) {
146
+ const entry = tokens.get(token);
147
+ tokens.delete(token);
148
+ if (entry && entry.key && tokenByKey.get(entry.key) === token) {
149
+ tokenByKey.delete(entry.key);
150
+ }
151
+ }
152
+ }
153
+ };
104
154
 
105
155
  // ── Inventory (cached) ──────────────────────────────────────────────────
106
156
  let cache = null; // { at: number, payload: object }
157
+ let discoveryCache = null; // { at: number, value: object }
158
+ let building = null; // in-flight build promise (request coalescing)
107
159
 
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;
160
+ async function getDiscovery(force) {
161
+ const fresh = discoveryCache && Date.now() - discoveryCache.at < discoveryTtlMs;
162
+ if (!force && fresh) return discoveryCache.value;
163
+ const value = await discoverFn();
164
+ discoveryCache = { at: Date.now(), value };
165
+ return value;
116
166
  }
117
167
 
118
- function buildInventory(force) {
119
- tokens.clear(); // re-mint: tokens from older builds stop resolving
120
- return core.buildInventoryFrom(getDiscovery(force), { mint });
168
+ async function buildInventory(force) {
169
+ if (force) remintAll(); // explicit refresh: stale URLs die on purpose
170
+ const payload = await core.buildInventoryFrom(await getDiscovery(force), {
171
+ ...asyncIo,
172
+ mint,
173
+ base: BASE,
174
+ });
175
+ pruneTokens(payload);
176
+ return payload;
121
177
  }
122
178
 
123
179
  function getInventory(force) {
124
- if (!force && cache && Date.now() - cache.at < INVENTORY_TTL_MS) return cache.payload;
125
- const payload = buildInventory(force);
126
- cache = { at: Date.now(), payload };
127
- return payload;
180
+ if (!force && cache && Date.now() - cache.at < inventoryTtlMs) {
181
+ return Promise.resolve(cache.payload);
182
+ }
183
+ // Coalesce concurrent builds (double-clicked 刷新, parallel clients).
184
+ if (building) return building;
185
+ building = buildInventory(force)
186
+ .then((payload) => { cache = { at: Date.now(), payload }; return payload; })
187
+ .finally(() => { building = null; });
188
+ return building;
128
189
  }
129
190
 
130
191
  // ── Response helpers ────────────────────────────────────────────────────
131
- function sendJson(res, status, body) {
192
+ function sendJson(req, res, status, body) {
193
+ const text = JSON.stringify(body);
132
194
  res.statusCode = status;
133
195
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
134
196
  res.setHeader('Cache-Control', 'no-store');
135
- res.end(JSON.stringify(body));
197
+ res.setHeader('Content-Length', String(Buffer.byteLength(text)));
198
+ res.end(req.method === 'HEAD' ? undefined : text);
136
199
  }
137
200
 
138
201
  /** Stream one file with Range support (video seeking) and HEAD handling. */
139
202
  function serveFile(absPath, req, res) {
140
- let st;
141
- try { st = statSync(absPath); } catch { st = null; }
142
- if (!st || !st.isFile()) {
143
- res.statusCode = 404;
144
- res.end('not found');
145
- return;
146
- }
147
- res.setHeader('Content-Type', core.mimeFor(absPath));
148
- res.setHeader('Accept-Ranges', 'bytes');
149
- res.setHeader('Cache-Control', 'no-store');
150
- res.setHeader('X-Content-Type-Options', 'nosniff');
203
+ fsp.stat(absPath).then((st) => {
204
+ if (!st.isFile()) {
205
+ res.statusCode = 404;
206
+ res.setHeader('Content-Length', '0');
207
+ res.end();
208
+ return;
209
+ }
210
+ res.setHeader('Content-Type', core.mimeFor(absPath));
211
+ res.setHeader('Accept-Ranges', 'bytes');
212
+ res.setHeader('Cache-Control', 'no-store');
213
+ res.setHeader('X-Content-Type-Options', 'nosniff');
151
214
 
152
- const range = core.parseRangeHeader(req.headers.range, st.size);
153
- if (range && range.error) {
154
- res.statusCode = 416;
155
- res.setHeader('Content-Range', `bytes */${st.size}`);
156
- res.end();
157
- return;
158
- }
159
- const head = req.method === 'HEAD';
160
- if (range) {
161
- res.statusCode = 206;
162
- res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${st.size}`);
163
- res.setHeader('Content-Length', String(range.end - range.start + 1));
164
- } else {
165
- res.setHeader('Content-Length', String(st.size));
166
- }
167
- if (head) { res.end(); return; }
215
+ const range = core.parseRangeHeader(req.headers.range, st.size);
216
+ if (range && range.error) {
217
+ res.statusCode = 416;
218
+ res.setHeader('Content-Range', `bytes */${st.size}`);
219
+ res.setHeader('Content-Length', '0');
220
+ res.end();
221
+ return;
222
+ }
223
+ const head = req.method === 'HEAD';
224
+ if (range) {
225
+ res.statusCode = 206;
226
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${st.size}`);
227
+ res.setHeader('Content-Length', String(range.end - range.start + 1));
228
+ } else {
229
+ res.setHeader('Content-Length', String(st.size));
230
+ }
231
+ if (head) { res.end(); return; }
168
232
 
169
- const stream = range
170
- ? createReadStream(absPath, { start: range.start, end: range.end })
171
- : createReadStream(absPath);
172
- stream.on('error', () => {
173
- if (!res.headersSent) res.statusCode = 500;
233
+ const stream = range
234
+ ? createReadStream(absPath, { start: range.start, end: range.end })
235
+ : createReadStream(absPath);
236
+ // The file can vanish between stat and open; ending normally would
237
+ // leave the already-announced Content-Length unsatisfied and the
238
+ // client hanging — destroy the response instead.
239
+ stream.on('error', () => { res.destroy(); });
240
+ stream.pipe(res);
241
+ }).catch(() => {
242
+ res.statusCode = 404;
243
+ res.setHeader('Content-Length', '0');
174
244
  res.end();
175
245
  });
176
- stream.pipe(res);
177
246
  }
178
247
 
179
248
  // ── Routes ──────────────────────────────────────────────────────────────
@@ -184,20 +253,15 @@ export function apply(ctx) {
184
253
  path: `${BASE}/inventory`,
185
254
  handler: (req, res) => {
186
255
  if (req.method !== 'GET' && req.method !== 'HEAD') {
187
- sendJson(res, 405, { error: 'method not allowed' });
256
+ sendJson(req, res, 405, { error: 'method not allowed' });
188
257
  return;
189
258
  }
190
- try {
191
- const url = new URL(req.url || '/', 'http://x');
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);
198
- } catch (err) {
199
- sendJson(res, 500, { error: String(err && err.message ? err.message : err) });
200
- }
259
+ const url = new URL(req.url || '/', 'http://x');
260
+ getInventory(url.searchParams.get('refresh') === '1')
261
+ .then((payload) => sendJson(req, res, 200, payload))
262
+ .catch((err) => {
263
+ sendJson(req, res, 500, { error: String(err && err.message ? err.message : err) });
264
+ });
201
265
  },
202
266
  }));
203
267
 
@@ -208,14 +272,14 @@ export function apply(ctx) {
208
272
  path: `${BASE}/${seg}`,
209
273
  handler: (req, res) => {
210
274
  if (req.method !== 'GET' && req.method !== 'HEAD') {
211
- sendJson(res, 405, { error: 'method not allowed' });
275
+ sendJson(req, res, 405, { error: 'method not allowed' });
212
276
  return;
213
277
  }
214
278
  let pathname;
215
279
  try {
216
280
  pathname = new URL(req.url || '/', 'http://x').pathname;
217
281
  } catch {
218
- sendJson(res, 400, { error: 'bad request' });
282
+ sendJson(req, res, 400, { error: 'bad request' });
219
283
  return;
220
284
  }
221
285
  const rest = pathname.slice(prefix.length); // "<token>" | "<token>/<rel…>"
@@ -223,14 +287,14 @@ export function apply(ctx) {
223
287
  const rawToken = slash < 0 ? rest : rest.slice(0, slash);
224
288
  let token;
225
289
  try { token = decodeURIComponent(rawToken); } catch {
226
- sendJson(res, 400, { error: 'bad token' });
290
+ sendJson(req, res, 400, { error: 'bad token' });
227
291
  return;
228
292
  }
229
293
  const entry = tokens.get(token);
230
294
  if (!entry) {
231
295
  res.statusCode = 404;
232
- if (req.method === 'HEAD') res.end();
233
- else res.end('not found');
296
+ res.setHeader('Content-Length', '0');
297
+ res.end();
234
298
  return;
235
299
  }
236
300
  let abs = entry.abs;
@@ -238,22 +302,25 @@ export function apply(ctx) {
238
302
  // Sub-asset of a bundled web wallpaper: must stay inside its dir.
239
303
  if (seg !== 'media' || !entry.rootDir) {
240
304
  res.statusCode = 404;
241
- if (req.method === 'HEAD') res.end();
242
- else res.end('not found');
305
+ res.setHeader('Content-Length', '0');
306
+ res.end();
243
307
  return;
244
308
  }
245
309
  let rel;
246
310
  try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
247
- sendJson(res, 400, { error: 'bad path' });
311
+ sendJson(req, res, 400, { error: 'bad path' });
248
312
  return;
249
313
  }
250
- if (!rel || rel.split('/').some((part) => part === '..' || part === '')) {
251
- sendJson(res, 403, { error: 'forbidden' });
314
+ // Backslash parts are never legitimate in a URL path here; on win32
315
+ // `..\evil` would slip past a `/`-only split (the isInsideDir
316
+ // backstop still catches it — this is defense in depth).
317
+ if (!rel || rel.split('/').some((part) => part === '..' || part === '' || part.includes('\\'))) {
318
+ sendJson(req, res, 403, { error: 'forbidden' });
252
319
  return;
253
320
  }
254
321
  abs = join(entry.rootDir, ...rel.split('/'));
255
322
  if (!core.isInsideDir(entry.rootDir, abs)) {
256
- sendJson(res, 403, { error: 'forbidden' });
323
+ sendJson(req, res, 403, { error: 'forbidden' });
257
324
  return;
258
325
  }
259
326
  }
@@ -262,17 +329,24 @@ export function apply(ctx) {
262
329
  }));
263
330
  }
264
331
 
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 */ }
332
+ // Warm the discovery cache now (async, off the request path) so the first
333
+ // inventory request answers quickly.
334
+ getDiscovery(false).catch(() => { /* non-fatal: inventory will 500 with reason */ });
268
335
 
269
336
  return () => {
270
337
  for (const d of disposers) { try { d(); } catch { /* ignore */ } }
271
338
  disposers.length = 0;
272
- tokens.clear();
339
+ remintAll();
273
340
  cache = null;
274
341
  discoveryCache = null;
275
342
  };
276
343
  }
277
344
 
345
+ export const inject = ['webServer'];
346
+
347
+ export function apply(ctx) {
348
+ // inject guarantees the service is present before apply runs.
349
+ return createRouteRegistrar(ctx.webServer);
350
+ }
351
+
278
352
  export default { inject, apply };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "0.1.5",
4
- "description": "Wallpaper Engine Video/Web wallpapers as the DSH web GUI background — discovery, sandboxed rendering, rotation lists, liquid-glass tuning.",
3
+ "version": "0.1.7",
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",
7
7
  "types": "lib/types/index.d.ts",
@@ -9,10 +9,14 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/Weilv-D/wallpaper-engine-dsh.git"
11
11
  },
12
+ "bugs": "https://github.com/Weilv-D/wallpaper-engine-dsh/issues",
13
+ "homepage": "https://github.com/Weilv-D/wallpaper-engine-dsh#readme",
12
14
  "keywords": [
13
15
  "dsh",
14
16
  "dsh-plugin",
17
+ "deepseek",
15
18
  "deepseek-harness",
19
+ "cordis",
16
20
  "wallpaper",
17
21
  "wallpaper-engine",
18
22
  "background",
@@ -59,7 +63,7 @@
59
63
  "scripts": {
60
64
  "build": "node scripts/build-client.mjs",
61
65
  "verify": "node scripts/verify-client.mjs",
62
- "test": "node --test test/core.test.mjs",
66
+ "test": "node --test test/core.test.mjs test/host.test.mjs",
63
67
  "prepare": "node scripts/prepare.mjs",
64
68
  "prepublishOnly": "npm run test && npm run build && npm run verify",
65
69
  "release": "npm version patch -m \"chore: release v%s\" && git push --follow-tags",