wallpaper-engine-dsh 0.1.0

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 ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * wallpaper-engine-dsh — host half.
3
+ *
4
+ * A Cordis plugin (loaded as an out-of-tree bundle row, see cordis.patch.yml)
5
+ * that bridges the local Wallpaper Engine install into the DSH web GUI.
6
+ * All parsing/enumeration logic lives in the pure layer (lib/core.js); this
7
+ * file only wires it into same-origin HTTP routes on `ctx.webServer`:
8
+ *
9
+ * GET /we-background/inventory[?refresh=1] → { installDir, wallpapers, playlists }
10
+ * GET /we-background/media/<token>[/<sub-asset>…] → video / web HTML + assets
11
+ * GET /we-background/preview/<token> → preview image
12
+ *
13
+ * 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.
18
+ * - Web-wallpaper sub-assets resolve strictly INSIDE their project
19
+ * directory (core.isInsideDir) — `..` traversal is answered 403.
20
+ * - project.json-declared files must also be inside the project directory
21
+ * (core.readProject), so a hostile workshop item cannot point the server
22
+ * at arbitrary files.
23
+ * - The browser half renders web wallpapers in a sandboxed iframe
24
+ * (`sandbox="allow-scripts"`, no allow-same-origin), so third-party
25
+ * wallpaper JS gets an opaque origin and cannot touch DSH storage/APIs.
26
+ * - Everything is loopback same-origin; `X-Content-Type-Options: nosniff`
27
+ * on every media response.
28
+ *
29
+ * Lifecycle: every route registers through the plugin fiber and unwinds on
30
+ * unload; the inventory cache and token map are dropped with it. The plugin
31
+ * contributes no model-visible tool and no prompt text.
32
+ */
33
+
34
+ import {
35
+ createReadStream,
36
+ existsSync,
37
+ readFileSync,
38
+ statSync,
39
+ } from 'node:fs';
40
+ import { execFileSync } from 'node:child_process';
41
+ import { randomBytes } from 'node:crypto';
42
+ import { join } from 'node:path';
43
+
44
+ import * as core from './core.js';
45
+
46
+ /** Request path prefix under which this bundle's HTTP surface lives. */
47
+ const BASE = '/we-background';
48
+ /** Inventory is cached and rebuilt at most this often unless ?refresh=1. */
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
+
53
+ /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
54
+ function steamRegistryRoot() {
55
+ if (process.platform !== 'win32') return null;
56
+ try {
57
+ const reg = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe');
58
+ const out = execFileSync(
59
+ reg,
60
+ ['query', 'HKCU\\Software\\Valve\\Steam', '/v', 'SteamPath'],
61
+ { encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
62
+ );
63
+ return core.steamPathFromRegQuery(out);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** Full discovery pass: install dir + every library owning WE content. */
70
+ function discover() {
71
+ const probes = core.steamProbeDirs();
72
+ const registryRoot = steamRegistryRoot();
73
+ const searchRoots = registryRoot ? [registryRoot, ...probes] : probes;
74
+ const vdfLibraries = [];
75
+ for (const root of searchRoots) {
76
+ const vdf = join(root, 'steamapps', 'libraryfolders.vdf');
77
+ if (!existsSync(vdf)) continue;
78
+ try {
79
+ vdfLibraries.push(...core.librariesFromVdfText(readFileSync(vdf, 'utf8')));
80
+ } catch { /* unreadable vdf → skip this root */ }
81
+ }
82
+ const installDir = core.findInstallDir(
83
+ core.installDirCandidates({ registryRoot, libraryRoots: vdfLibraries }),
84
+ );
85
+ const libraryRoots = core.workshopLibraryRoots(searchRoots, vdfLibraries);
86
+ return { installDir, libraryRoots };
87
+ }
88
+
89
+ export const inject = ['webServer'];
90
+
91
+ export function apply(ctx) {
92
+ const webServer = ctx.webServer;
93
+ if (!webServer || typeof webServer.register !== 'function') {
94
+ return () => {}; // defensive: inject guarantees the service in practice
95
+ }
96
+
97
+ // ── Token registry ──────────────────────────────────────────────────────
98
+ // token → { abs, rootDir|null }. rootDir is set for renderable projects so
99
+ // web wallpapers can fetch their bundled sub-assets (contained, see below).
100
+ const tokens = new Map();
101
+ const mint = (entry) => {
102
+ const token = randomBytes(9).toString('base64url'); // 72 bits, url-safe
103
+ tokens.set(token, entry);
104
+ return token;
105
+ };
106
+
107
+ // ── Inventory (cached) ──────────────────────────────────────────────────
108
+ let cache = null; // { at: number, payload: object }
109
+
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
+ });
165
+
166
+ return {
167
+ installDir,
168
+ total: wallpapers.length,
169
+ portableCount: wallpapers.filter((w) => w.playable).length,
170
+ wallpapers,
171
+ playlists: playlistView,
172
+ };
173
+ }
174
+
175
+ function getInventory(force) {
176
+ if (!force && cache && Date.now() - cache.at < INVENTORY_TTL_MS) return cache.payload;
177
+ const payload = buildInventory();
178
+ cache = { at: Date.now(), payload };
179
+ return payload;
180
+ }
181
+
182
+ // ── Response helpers ────────────────────────────────────────────────────
183
+ function sendJson(res, status, body) {
184
+ res.statusCode = status;
185
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
186
+ res.setHeader('Cache-Control', 'no-store');
187
+ res.end(JSON.stringify(body));
188
+ }
189
+
190
+ /** Stream one file with Range support (video seeking) and HEAD handling. */
191
+ function serveFile(absPath, req, res) {
192
+ let st;
193
+ try { st = statSync(absPath); } catch { st = null; }
194
+ if (!st || !st.isFile()) {
195
+ res.statusCode = 404;
196
+ res.end('not found');
197
+ return;
198
+ }
199
+ res.setHeader('Content-Type', core.mimeFor(absPath));
200
+ res.setHeader('Accept-Ranges', 'bytes');
201
+ res.setHeader('Cache-Control', 'no-store');
202
+ res.setHeader('X-Content-Type-Options', 'nosniff');
203
+
204
+ const range = core.parseRangeHeader(req.headers.range, st.size);
205
+ if (range && range.error) {
206
+ res.statusCode = 416;
207
+ res.setHeader('Content-Range', `bytes */${st.size}`);
208
+ res.end();
209
+ return;
210
+ }
211
+ const head = req.method === 'HEAD';
212
+ if (range) {
213
+ res.statusCode = 206;
214
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${st.size}`);
215
+ res.setHeader('Content-Length', String(range.end - range.start + 1));
216
+ } else {
217
+ res.setHeader('Content-Length', String(st.size));
218
+ }
219
+ if (head) { res.end(); return; }
220
+
221
+ const stream = range
222
+ ? createReadStream(absPath, { start: range.start, end: range.end })
223
+ : createReadStream(absPath);
224
+ stream.on('error', () => {
225
+ if (!res.headersSent) res.statusCode = 500;
226
+ res.end();
227
+ });
228
+ stream.pipe(res);
229
+ }
230
+
231
+ // ── Routes ──────────────────────────────────────────────────────────────
232
+ const disposers = [];
233
+
234
+ disposers.push(webServer.register({
235
+ kind: 'exact',
236
+ path: `${BASE}/inventory`,
237
+ handler: (req, res) => {
238
+ if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return; }
239
+ try {
240
+ const url = new URL(req.url || '/', 'http://x');
241
+ sendJson(res, 200, getInventory(url.searchParams.get('refresh') === '1'));
242
+ } catch (err) {
243
+ sendJson(res, 500, { error: String(err && err.message ? err.message : err) });
244
+ }
245
+ },
246
+ }));
247
+
248
+ for (const seg of ['media', 'preview']) {
249
+ const prefix = `${BASE}/${seg}/`;
250
+ disposers.push(webServer.register({
251
+ kind: 'prefix',
252
+ path: `${BASE}/${seg}`,
253
+ handler: (req, res) => {
254
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
255
+ sendJson(res, 405, { error: 'method not allowed' });
256
+ return;
257
+ }
258
+ let pathname;
259
+ try {
260
+ pathname = new URL(req.url || '/', 'http://x').pathname;
261
+ } catch {
262
+ sendJson(res, 400, { error: 'bad request' });
263
+ return;
264
+ }
265
+ const rest = pathname.slice(prefix.length); // "<token>" | "<token>/<rel…>"
266
+ const slash = rest.indexOf('/');
267
+ const rawToken = slash < 0 ? rest : rest.slice(0, slash);
268
+ let token;
269
+ try { token = decodeURIComponent(rawToken); } catch {
270
+ sendJson(res, 400, { error: 'bad token' });
271
+ return;
272
+ }
273
+ const entry = tokens.get(token);
274
+ if (!entry) {
275
+ res.statusCode = 404;
276
+ res.end('not found');
277
+ return;
278
+ }
279
+ let abs = entry.abs;
280
+ if (slash >= 0) {
281
+ // Sub-asset of a bundled web wallpaper: must stay inside its dir.
282
+ if (seg !== 'media' || !entry.rootDir) {
283
+ res.statusCode = 404;
284
+ res.end('not found');
285
+ return;
286
+ }
287
+ let rel;
288
+ try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch {
289
+ sendJson(res, 400, { error: 'bad path' });
290
+ return;
291
+ }
292
+ if (!rel || rel.split('/').some((part) => part === '..' || part === '')) {
293
+ sendJson(res, 403, { error: 'forbidden' });
294
+ return;
295
+ }
296
+ abs = join(entry.rootDir, ...rel.split('/'));
297
+ if (!core.isInsideDir(entry.rootDir, abs)) {
298
+ sendJson(res, 403, { error: 'forbidden' });
299
+ return;
300
+ }
301
+ }
302
+ serveFile(abs, req, res);
303
+ },
304
+ }));
305
+ }
306
+
307
+ return () => {
308
+ for (const d of disposers) { try { d(); } catch { /* ignore */ } }
309
+ disposers.length = 0;
310
+ tokens.clear();
311
+ cache = null;
312
+ };
313
+ }
314
+
315
+ export default { inject, apply };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * wallpaper-engine-dsh — client-half type surface.
3
+ * Loaded by the DSH web module loader; consumes the slots service.
4
+ */
5
+
6
+ export interface ClientContext {
7
+ slots?: {
8
+ inject(key: string, callback: () => unknown): unknown;
9
+ register(
10
+ options: { name: string; id: string; order?: number; label?: string },
11
+ render: () => unknown,
12
+ ): unknown;
13
+ };
14
+ effect?(fn: () => void | (() => void)): unknown;
15
+ }
16
+
17
+ export declare const inject: string[];
18
+ export declare function apply(ctx: ClientContext): void;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * wallpaper-engine-dsh — host-half type surface.
3
+ * The runtime contract is a Cordis plugin module: named `inject` + `apply`,
4
+ * mirrored on the default export.
5
+ */
6
+
7
+ export interface WallpaperSummary {
8
+ id: string;
9
+ title: string;
10
+ type: 'scene' | 'video' | 'web' | 'application';
11
+ playable: boolean;
12
+ media: string | null;
13
+ preview: string | null;
14
+ }
15
+
16
+ export interface PlaylistSummary {
17
+ id: string;
18
+ name: string;
19
+ order: 'sequence' | 'random';
20
+ delay: number | null;
21
+ wallpaperIds: string[];
22
+ total: number;
23
+ portableCount: number;
24
+ unresolvedCount: number;
25
+ }
26
+
27
+ export interface Inventory {
28
+ installDir: string | null;
29
+ total: number;
30
+ portableCount: number;
31
+ wallpapers: WallpaperSummary[];
32
+ playlists: PlaylistSummary[];
33
+ }
34
+
35
+ /** Cordis context shape this plugin consumes (structural, minimal). */
36
+ export interface HostContext {
37
+ webServer?: {
38
+ register(route: {
39
+ kind: 'exact' | 'prefix';
40
+ path: string;
41
+ handler(req: unknown, res: unknown): void | Promise<void>;
42
+ }): () => void;
43
+ };
44
+ }
45
+
46
+ export declare const inject: string[];
47
+ export declare function apply(ctx: HostContext): () => void;
48
+
49
+ declare const plugin: { inject: string[]; apply: typeof apply };
50
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "wallpaper-engine-dsh",
3
+ "version": "0.1.0",
4
+ "description": "Wallpaper Engine Video/Web wallpapers as the DSH web GUI background — discovery, sandboxed rendering, rotation lists, liquid-glass tuning.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Weilv-D/wallpaper-engine-dsh.git"
11
+ },
12
+ "keywords": [
13
+ "dsh",
14
+ "dsh-plugin",
15
+ "deepseek-harness",
16
+ "wallpaper",
17
+ "wallpaper-engine",
18
+ "background",
19
+ "theme"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./lib/types/index.d.ts",
27
+ "default": "./lib/index.js"
28
+ },
29
+ "./client": {
30
+ "types": "./lib/types/client.d.ts",
31
+ "default": "./lib/client.js"
32
+ },
33
+ "./cordis.patch.yml": "./cordis.patch.yml",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib/core.js",
38
+ "lib/index.js",
39
+ "lib/client.js",
40
+ "lib/types/index.d.ts",
41
+ "lib/types/client.d.ts",
42
+ "cordis.patch.yml",
43
+ "README.md",
44
+ "README.zh.md"
45
+ ],
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-runtime"
53
+ ],
54
+ "platform": "web",
55
+ "immediately": true
56
+ }
57
+ },
58
+ "license": "MIT",
59
+ "scripts": {
60
+ "build": "node scripts/build-client.mjs",
61
+ "verify": "node scripts/verify-client.mjs",
62
+ "test": "node --test test/core.test.mjs",
63
+ "prepare": "node scripts/prepare.mjs",
64
+ "prepublishOnly": "npm run test && npm run build && npm run verify"
65
+ },
66
+ "engines": {
67
+ "node": ">=18.17"
68
+ },
69
+ "peerDependencies": {
70
+ "@deepseek-ai/cordis": "^4.0.1",
71
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6",
74
+ "react": "^18.2.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@deepseek-ai/dsh-host-webserver": {
78
+ "optional": true
79
+ }
80
+ }
81
+ }