dsh-any-background 0.1.8 → 0.2.1

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
@@ -1,39 +1,63 @@
1
- import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createReadStream, createWriteStream } from "node:fs";
2
3
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
3
4
  //#region src/index.ts
4
5
  /**
5
- * Node half of the dsh-any-background plugin: file-backed theme persistence.
6
+ * Node half of dsh-any-background: file-backed theme persistence.
6
7
  *
7
- * The browser client cannot touch the filesystem, so this half owns the
8
- * `.dsh-any-background-data/` store under the DSH data home and exposes a
9
- * small RPC surface over the dedicated `/dsh-any-background` channel (never
10
- * the shared `/api`, so slash commands stay intact). The client reads the
11
- * persisted theme on boot and writes it back on every setting change.
8
+ * Owns the `~/.dsh/.dsh-any-background-data/` store and exposes a small RPC
9
+ * surface on the dedicated `/dsh-any-background` channel (never the shared
10
+ * `/api`, so slash commands stay intact).
12
11
  *
13
- * Storage layout:
14
- * ~/.dsh/.dsh-any-background-data/
15
- * ├── theme-config.json settings (color, opacities, blur, bg state)
16
- * └── wallpaper.jpg the chosen background image (deleted on remove)
17
- *
18
- * @module dsh-any-background
12
+ * theme-config.json settings
13
+ * wallpaper.jpg background image
14
+ * wallpaper.<ext> background video, named by MIME (mp4/webm/ogv/mov/mkv);
15
+ * played over HTTP route /dsh-any-background/video and
16
+ * uploaded to /dsh-any-background/video/upload as raw
17
+ * bytes — never base64 through the RPC channel.
19
18
  */
20
19
  const name = "dsh-any-background";
21
- const inject = ["connection"];
20
+ const inject = ["connection", "webServer"];
22
21
  const DATA_DIR = ".dsh-any-background-data";
23
22
  const CONFIG_FILE = "theme-config.json";
24
23
  const WALLPAPER_FILE = "wallpaper.jpg";
24
+ const VIDEO_ROUTE = "/dsh-any-background/video";
25
+ const UPLOAD_ROUTE = "/dsh-any-background/video/upload";
26
+ const UPLOAD_TMP = "wallpaper.upload.tmp";
27
+ function videoFileName(mime) {
28
+ switch (mime) {
29
+ case "video/mp4": return "wallpaper.mp4";
30
+ case "video/webm": return "wallpaper.webm";
31
+ case "video/ogg": return "wallpaper.ogv";
32
+ case "video/quicktime": return "wallpaper.mov";
33
+ case "video/x-matroska": return "wallpaper.mkv";
34
+ default: return "wallpaper.video";
35
+ }
36
+ }
37
+ const VIDEO_CANDIDATES = [
38
+ "wallpaper.mp4",
39
+ "wallpaper.webm",
40
+ "wallpaper.ogv",
41
+ "wallpaper.mov",
42
+ "wallpaper.mkv",
43
+ "wallpaper.video"
44
+ ];
25
45
  const DEFAULT_CONFIG = {
26
46
  color: null,
27
47
  opacities: {
28
48
  bg: .85,
29
49
  sidebar: .93,
30
- card: 1
50
+ card: 1,
51
+ input: 1
31
52
  },
32
53
  blurs: {
33
54
  bg: 0,
34
55
  sidebar: 0,
35
56
  card: 0,
36
- settings: 0
57
+ settings: 0,
58
+ chat: 0,
59
+ trajectory: 0,
60
+ input: 0
37
61
  },
38
62
  settingsOpacity: 1,
39
63
  wallpaperOpacity: 1,
@@ -45,16 +69,73 @@ const DEFAULT_CONFIG = {
45
69
  iw: 0,
46
70
  ih: 0
47
71
  },
72
+ videoBgState: {
73
+ zoom: 1,
74
+ x: 0,
75
+ y: 0,
76
+ iw: 0,
77
+ ih: 0
78
+ },
48
79
  backgroundType: "image",
80
+ bgMode: "fit",
81
+ videoMime: null,
49
82
  generatedBg: null,
50
- regenerateOnReload: false
83
+ regenerateOnReload: false,
84
+ chatTextOpacity: 0,
85
+ trajectoryOpacity: 1
51
86
  };
52
87
  const dataDir = () => dshHomePath(DATA_DIR);
53
88
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
54
89
  const wallpaperPath = () => dshHomePath(DATA_DIR, WALLPAPER_FILE);
90
+ const videoPathFor = (mime) => dshHomePath(DATA_DIR, videoFileName(mime));
91
+ const exists = async (p) => {
92
+ try {
93
+ await access(p);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ };
99
+ /** Locate the stored video: the recorded MIME decides the expected name; a
100
+ * legacy extensionless wallpaper.video is renamed on first access. */
101
+ async function findVideoFile() {
102
+ const cfg = await readConfig();
103
+ const expected = videoPathFor(cfg.videoMime);
104
+ if (await exists(expected)) return {
105
+ path: expected,
106
+ mime: cfg.videoMime
107
+ };
108
+ for (const name of VIDEO_CANDIDATES) {
109
+ const p = dshHomePath(DATA_DIR, name);
110
+ if (!await exists(p)) continue;
111
+ if (cfg.videoMime !== null && name !== videoFileName(cfg.videoMime)) try {
112
+ await rename(p, expected);
113
+ return {
114
+ path: expected,
115
+ mime: cfg.videoMime
116
+ };
117
+ } catch {
118
+ return null;
119
+ }
120
+ return {
121
+ path: p,
122
+ mime: cfg.videoMime
123
+ };
124
+ }
125
+ return null;
126
+ }
55
127
  function clamp(n, lo, hi, def) {
56
128
  return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
57
129
  }
130
+ function normalizeBgState(s) {
131
+ return {
132
+ zoom: clamp(s.zoom, .1, 10, 1),
133
+ x: typeof s.x === "number" && isFinite(s.x) ? s.x : 0,
134
+ y: typeof s.y === "number" && isFinite(s.y) ? s.y : 0,
135
+ iw: typeof s.iw === "number" && s.iw > 0 ? s.iw : 0,
136
+ ih: typeof s.ih === "number" && s.ih > 0 ? s.ih : 0
137
+ };
138
+ }
58
139
  /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
59
140
  function normalizeConfig(raw) {
60
141
  const r = raw ?? {};
@@ -64,44 +145,56 @@ function normalizeConfig(raw) {
64
145
  clamp(c[1], 0, 1, .55),
65
146
  clamp(c[2], 0, 1, .25)
66
147
  ] : null;
67
- const bg = r.bgState ?? {};
68
148
  const bgType = [
69
149
  "image",
150
+ "video",
70
151
  "mesh",
71
152
  "shader",
72
153
  "pattern"
73
154
  ].includes(r.backgroundType) ? r.backgroundType : DEFAULT_CONFIG.backgroundType;
155
+ const bgMode = [
156
+ "fit",
157
+ "fill",
158
+ "stretch",
159
+ "tile",
160
+ "center"
161
+ ].includes(r.bgMode) ? r.bgMode : DEFAULT_CONFIG.bgMode;
74
162
  const gen = r.generatedBg && typeof r.generatedBg === "object" ? r.generatedBg : null;
75
163
  const generatedBg = gen && gen.type === bgType ? normalizeGeneratedBg(r.generatedBg) : null;
76
164
  const legacy = typeof r.opacity === "number" ? r.opacity : null;
77
165
  const ops = r.opacities ?? {};
78
166
  const bl = r.blurs ?? {};
167
+ const blurs = {};
168
+ for (const k of [
169
+ "bg",
170
+ "sidebar",
171
+ "card",
172
+ "settings",
173
+ "chat",
174
+ "trajectory",
175
+ "input"
176
+ ]) blurs[k] = clamp(bl[k], 0, 60, DEFAULT_CONFIG.blurs[k]);
79
177
  return {
80
178
  color,
81
179
  opacities: {
82
180
  bg: clamp(ops.bg, 0, 1, legacy ?? DEFAULT_CONFIG.opacities.bg),
83
181
  sidebar: clamp(ops.sidebar, 0, 1, legacy !== null ? Math.min(1, legacy + .08) : DEFAULT_CONFIG.opacities.sidebar),
84
- card: clamp(ops.card, 0, 1, DEFAULT_CONFIG.opacities.card)
85
- },
86
- blurs: {
87
- bg: clamp(bl.bg, 0, 60, DEFAULT_CONFIG.blurs.bg),
88
- sidebar: clamp(bl.sidebar, 0, 60, DEFAULT_CONFIG.blurs.sidebar),
89
- card: clamp(bl.card, 0, 60, DEFAULT_CONFIG.blurs.card),
90
- settings: clamp(bl.settings, 0, 60, DEFAULT_CONFIG.blurs.settings)
182
+ card: clamp(ops.card, 0, 1, DEFAULT_CONFIG.opacities.card),
183
+ input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
91
184
  },
185
+ blurs,
92
186
  settingsOpacity: clamp(r.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
93
187
  wallpaperOpacity: clamp(r.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
94
188
  blur: clamp(r.blur, 0, 60, DEFAULT_CONFIG.blur),
95
- bgState: {
96
- zoom: clamp(bg.zoom, .1, 10, 1),
97
- x: typeof bg.x === "number" && isFinite(bg.x) ? bg.x : 0,
98
- y: typeof bg.y === "number" && isFinite(bg.y) ? bg.y : 0,
99
- iw: typeof bg.iw === "number" && bg.iw > 0 ? bg.iw : 0,
100
- ih: typeof bg.ih === "number" && bg.ih > 0 ? bg.ih : 0
101
- },
189
+ bgState: normalizeBgState(r.bgState ?? {}),
190
+ videoBgState: normalizeBgState(r.videoBgState ?? {}),
102
191
  backgroundType: bgType,
192
+ bgMode,
193
+ videoMime: typeof r.videoMime === "string" ? r.videoMime : null,
103
194
  generatedBg,
104
- regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload
195
+ regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
196
+ chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
197
+ trajectoryOpacity: clamp(r.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity)
105
198
  };
106
199
  }
107
200
  function normalizeGeneratedBg(p) {
@@ -144,16 +237,10 @@ async function ensureDir() {
144
237
  }
145
238
  async function readConfig() {
146
239
  await ensureDir();
147
- try {
148
- await access(configPath());
149
- } catch {
150
- return { ...DEFAULT_CONFIG };
151
- }
152
240
  try {
153
241
  const raw = await readFile(configPath(), "utf8");
154
242
  return normalizeConfig(JSON.parse(raw));
155
- } catch (e) {
156
- console.warn(`dsh-any-background: cannot read "${CONFIG_FILE}", using defaults`, e);
243
+ } catch {
157
244
  return { ...DEFAULT_CONFIG };
158
245
  }
159
246
  }
@@ -167,7 +254,6 @@ async function writeConfig(config) {
167
254
  return false;
168
255
  }
169
256
  }
170
- /** Read the wallpaper file and return it as a JPEG data URL (null when absent). */
171
257
  async function readWallpaper() {
172
258
  try {
173
259
  return `data:image/jpeg;base64,${(await readFile(wallpaperPath())).toString("base64")}`;
@@ -175,11 +261,7 @@ async function readWallpaper() {
175
261
  return null;
176
262
  }
177
263
  }
178
- /**
179
- * Persist a wallpaper. Passing null removes the stored file; otherwise the
180
- * data URL is decoded and written to wallpaper.jpg. Returns false (and keeps
181
- * the previous file) when the payload is invalid or the write fails.
182
- */
264
+ /** Persist a wallpaper (null removes it); false keeps the previous file. */
183
265
  async function writeWallpaper(dataUrl) {
184
266
  await ensureDir();
185
267
  try {
@@ -196,6 +278,174 @@ async function writeWallpaper(dataUrl) {
196
278
  return false;
197
279
  }
198
280
  }
281
+ async function videoUrl() {
282
+ return await findVideoFile() ? VIDEO_ROUTE : null;
283
+ }
284
+ /** Persist a video from a data URL (null removes every variant); only used
285
+ * for removal and small legacy/import payloads. */
286
+ async function writeVideo(dataUrl) {
287
+ await ensureDir();
288
+ try {
289
+ if (dataUrl === null) {
290
+ for (const name of VIDEO_CANDIDATES) await rm(dshHomePath(DATA_DIR, name), { force: true });
291
+ return true;
292
+ }
293
+ const m = /^data:(video\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
294
+ if (!m) return false;
295
+ const target = videoPathFor(m[1]);
296
+ for (const name of VIDEO_CANDIDATES) {
297
+ const p = dshHomePath(DATA_DIR, name);
298
+ if (p !== target) await rm(p, { force: true });
299
+ }
300
+ await writeFile(target, Buffer.from(m[2], "base64"));
301
+ return true;
302
+ } catch (e) {
303
+ console.error("dsh-any-background: failed to write the background video", e);
304
+ return false;
305
+ }
306
+ }
307
+ /** Stream the stored video: correct MIME, no caching, Range answers so the
308
+ * browser can seek (snapshot capture does). */
309
+ async function serveVideo(req, res) {
310
+ if (req.method !== "GET" && req.method !== "HEAD") {
311
+ res.writeHead(405, { "Content-Type": "application/json" });
312
+ res.end(JSON.stringify({
313
+ ok: false,
314
+ error: "video route only serves GET/HEAD; uploads need the plugin upload route — restart the web server to load it"
315
+ }));
316
+ return;
317
+ }
318
+ try {
319
+ const found = await findVideoFile();
320
+ if (found === null) {
321
+ res.writeHead(404);
322
+ res.end("no background video stored");
323
+ return;
324
+ }
325
+ const st = await stat(found.path);
326
+ const baseHeaders = {
327
+ "Content-Type": found.mime ?? "application/octet-stream",
328
+ "Accept-Ranges": "bytes",
329
+ "Cache-Control": "no-store"
330
+ };
331
+ const range = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
332
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range);
333
+ if (m !== null && (m[1] !== "" || m[2] !== "")) {
334
+ let start;
335
+ let end;
336
+ if (m[1] === "") {
337
+ const suffix = parseInt(m[2], 10);
338
+ start = Math.max(0, st.size - suffix);
339
+ end = st.size - 1;
340
+ } else {
341
+ start = parseInt(m[1], 10);
342
+ end = m[2] !== "" ? Math.min(parseInt(m[2], 10), st.size - 1) : st.size - 1;
343
+ }
344
+ if (start >= st.size || start > end) {
345
+ res.writeHead(416, { "Content-Range": `bytes */${st.size}` });
346
+ res.end();
347
+ return;
348
+ }
349
+ res.writeHead(206, {
350
+ ...baseHeaders,
351
+ "Content-Range": `bytes ${start}-${end}/${st.size}`,
352
+ "Content-Length": end - start + 1
353
+ });
354
+ if (req.method === "HEAD") {
355
+ res.end();
356
+ return;
357
+ }
358
+ createReadStream(found.path, {
359
+ start,
360
+ end
361
+ }).pipe(res);
362
+ return;
363
+ }
364
+ res.writeHead(200, {
365
+ ...baseHeaders,
366
+ "Content-Length": st.size
367
+ });
368
+ if (req.method === "HEAD") {
369
+ res.end();
370
+ return;
371
+ }
372
+ createReadStream(found.path).pipe(res);
373
+ } catch (e) {
374
+ console.error("dsh-any-background: failed to serve the background video", e);
375
+ try {
376
+ res.writeHead(500);
377
+ res.end();
378
+ } catch {}
379
+ }
380
+ }
381
+ /** Accept a raw video upload (POST): pipe the body into a temp file, then
382
+ * rename it into the MIME-derived slot. Aborted transfers clean up. */
383
+ async function handleVideoUpload(req, res) {
384
+ if (req.method !== "POST") {
385
+ res.writeHead(405);
386
+ res.end();
387
+ return;
388
+ }
389
+ const mime = (typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "").split(";")[0].trim();
390
+ if (!mime.startsWith("video/")) {
391
+ req.resume();
392
+ res.writeHead(415, { "Content-Type": "application/json" });
393
+ res.end(JSON.stringify({
394
+ ok: false,
395
+ error: "unsupported media type, expected video/*"
396
+ }));
397
+ return;
398
+ }
399
+ try {
400
+ await ensureDir();
401
+ const tmp = dshHomePath(DATA_DIR, UPLOAD_TMP);
402
+ const target = videoPathFor(mime);
403
+ const out = createWriteStream(tmp);
404
+ let failed = false;
405
+ const fail = () => {
406
+ if (failed) return;
407
+ failed = true;
408
+ out.destroy();
409
+ rm(tmp, { force: true });
410
+ };
411
+ req.on("aborted", fail);
412
+ req.on("error", fail);
413
+ out.on("error", () => {
414
+ fail();
415
+ try {
416
+ res.writeHead(500);
417
+ res.end();
418
+ } catch {}
419
+ });
420
+ req.pipe(out);
421
+ out.on("finish", async () => {
422
+ if (failed) return;
423
+ try {
424
+ for (const name of VIDEO_CANDIDATES) {
425
+ const p = dshHomePath(DATA_DIR, name);
426
+ if (p !== target) await rm(p, { force: true });
427
+ }
428
+ await rm(target, { force: true });
429
+ await rename(tmp, target);
430
+ res.writeHead(200, { "Content-Type": "application/json" });
431
+ res.end(JSON.stringify({ ok: true }));
432
+ } catch (e) {
433
+ console.error("dsh-any-background: failed to finalize the uploaded video", e);
434
+ rm(tmp, { force: true });
435
+ try {
436
+ res.writeHead(500);
437
+ res.end();
438
+ } catch {}
439
+ }
440
+ });
441
+ } catch (e) {
442
+ console.error("dsh-any-background: failed to accept the video upload", e);
443
+ try {
444
+ res.writeHead(500);
445
+ res.end();
446
+ } catch {}
447
+ }
448
+ }
199
449
  const NS = "dshAnyBackground";
200
450
  function apply(ctx) {
201
451
  const dispose = ctx.connection.rpc.handle("/dsh-any-background", async (ep, payload) => {
@@ -206,7 +456,8 @@ function apply(ctx) {
206
456
  ok: true,
207
457
  value: {
208
458
  config: await readConfig(),
209
- wallpaper: await readWallpaper()
459
+ wallpaper: await readWallpaper(),
460
+ videoUrl: await videoUrl()
210
461
  }
211
462
  };
212
463
  case "writeConfig": return {
@@ -217,6 +468,10 @@ function apply(ctx) {
217
468
  ok: true,
218
469
  value: await writeWallpaper(payload?.dataUrl ?? null)
219
470
  };
471
+ case "setVideo": return {
472
+ ok: true,
473
+ value: await writeVideo(payload?.dataUrl ?? null)
474
+ };
220
475
  default: return {
221
476
  ok: false,
222
477
  error: {
@@ -237,7 +492,19 @@ function apply(ctx) {
237
492
  };
238
493
  }
239
494
  }, { authority: "trusted-host" });
495
+ const disposeRoute = ctx.webServer.register({
496
+ kind: "prefix",
497
+ path: VIDEO_ROUTE,
498
+ handler: serveVideo
499
+ });
500
+ const disposeUpload = ctx.webServer.register({
501
+ kind: "exact",
502
+ path: UPLOAD_ROUTE,
503
+ handler: handleVideoUpload
504
+ });
240
505
  ctx.on("dispose", () => {
506
+ disposeRoute();
507
+ disposeUpload();
241
508
  dispose();
242
509
  });
243
510
  }
package/lib/invariant.js CHANGED
@@ -1,19 +1,8 @@
1
1
  //#region src/invariant.ts
2
2
  const PACKAGE_NAME = "dsh-any-background";
3
- /** Cordis companion plugin name. */
4
3
  const name = "dsh-any-background-invariant";
5
- /** Service required before the companion can reserve package ownership. */
6
4
  const inject = ["invariants"];
7
- /**
8
- * No runtime invariant: the plugin registers a theme definition through the
9
- * existing ThemeRuntime service and emits no cross-plugin mutable state.
10
- */
11
5
  const install = () => {};
12
- /**
13
- * Register this package's invariant companion.
14
- * @param ctx - Cordis context carrying the invariant service.
15
- * @returns the installed registration's disposer after setup succeeds.
16
- */
17
6
  const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
18
7
  //#endregion
19
8
  export { apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-background",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "description": "Appearance plugin for DeepSeek Harness: custom theme color (PS-style color wheel), background wallpaper with opacity/blur controls, and separate main/settings interface opacity.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -50,16 +50,17 @@
50
50
  "watch": "tsdown --watch",
51
51
  "typecheck": "tsc -p tsconfig.json"
52
52
  },
53
+ "dependencies": {
54
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6"
55
+ },
53
56
  "devDependencies": {
54
57
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
55
58
  "@types/node": "^26.2.0",
56
59
  "@types/react": "^19.2.18",
57
60
  "@types/react-dom": "^19.2.4",
58
61
  "tsdown": "^0.22.2",
59
- "typescript": "^7.0.2"
60
- },
61
- "dependencies": {
62
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6"
62
+ "typescript": "^7.0.2",
63
+ "unrun": "^0.3.1"
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@deepseek-ai/cordis": "^4.0.1",
@@ -102,5 +103,6 @@
102
103
  ],
103
104
  "immediately": true
104
105
  }
105
- }
106
+ },
107
+ "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
106
108
  }