dsh-background-by-model 0.2.4 → 0.3.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 CHANGED
@@ -1,51 +1,42 @@
1
- import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import { createReadStream, createWriteStream } from "node:fs";
1
+ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
2
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
4
3
  //#region src/index.ts
5
4
  /**
6
- * Node half of dsh-background-by-model: file-backed theme persistence.
5
+ * Node half of dsh-background-by-model: file-backed rule persistence.
7
6
  *
8
7
  * Owns the `~/.dsh/.dsh-background-by-model-data/` store and exposes a small RPC
9
8
  * surface on the dedicated `/dsh-background-by-model` channel (never the shared
10
9
  * `/api`, so slash commands stay intact).
11
10
  *
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-background-by-model/video and
16
- * uploaded to /dsh-background-by-model/video/upload as raw
17
- * bytes — never base64 through the RPC channel.
11
+ * theme-config.json the ordered rule list + the global interface settings
12
+ * modelbg-<slot> one background image per rule (raw bytes, no extension;
13
+ * the MIME is sniffed from the magic bytes when served)
14
+ *
15
+ * Images travel as data URLs over the RPC channel — there is no separate HTTP
16
+ * image route. A legacy single-wallpaper / video-background store is migrated
17
+ * to rule 1 on first read.
18
18
  */
19
19
  const name = "dsh-background-by-model";
20
20
  const inject = ["connection", "webServer"];
21
21
  const DATA_DIR = ".dsh-background-by-model-data";
22
22
  const CONFIG_FILE = "theme-config.json";
23
- const WALLPAPER_FILE = "wallpaper.jpg";
24
- const VIDEO_ROUTE = "/dsh-background-by-model/video";
25
- const UPLOAD_ROUTE = "/dsh-background-by-model/video/upload";
26
- const UPLOAD_TMP = "wallpaper.upload.tmp";
27
- const WALLPAPER_FETCH_MAX = 26214400;
28
- const WALLPAPER_FETCH_TIMEOUT = 2e4;
29
- function videoFileName(mime) {
30
- switch (mime) {
31
- case "video/mp4": return "wallpaper.mp4";
32
- case "video/webm": return "wallpaper.webm";
33
- case "video/ogg": return "wallpaper.ogv";
34
- case "video/quicktime": return "wallpaper.mov";
35
- case "video/x-matroska": return "wallpaper.mkv";
36
- default: return "wallpaper.video";
37
- }
38
- }
39
- const VIDEO_CANDIDATES = [
23
+ const IMAGE_PREFIX = "modelbg-";
24
+ const LEGACY_WALLPAPER = "wallpaper.jpg";
25
+ const LEGACY_FILES = [
40
26
  "wallpaper.mp4",
41
27
  "wallpaper.webm",
42
28
  "wallpaper.ogv",
43
29
  "wallpaper.mov",
44
30
  "wallpaper.mkv",
45
- "wallpaper.video"
31
+ "wallpaper.video",
32
+ "wallpaper.upload.tmp"
46
33
  ];
34
+ /** Slot names reach the filesystem, so they are strictly whitelisted. */
35
+ const SLOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
36
+ const WALLPAPER_FETCH_MAX = 26214400;
37
+ const WALLPAPER_FETCH_TIMEOUT = 2e4;
47
38
  const DEFAULT_CONFIG = {
48
- color: null,
39
+ rules: [],
49
40
  opacities: {
50
41
  bg: .85,
51
42
  sidebar: .93,
@@ -62,34 +53,13 @@ const DEFAULT_CONFIG = {
62
53
  input: 0
63
54
  },
64
55
  settingsOpacity: 1,
65
- wallpaperOpacity: 1,
66
- blur: 0,
67
- bgState: {
68
- zoom: 1,
69
- x: 0,
70
- y: 0,
71
- iw: 0,
72
- ih: 0
73
- },
74
- videoBgState: {
75
- zoom: 1,
76
- x: 0,
77
- y: 0,
78
- iw: 0,
79
- ih: 0
80
- },
81
- backgroundType: "image",
82
- bgMode: "fit",
83
- videoMime: null,
84
- generatedBg: null,
85
- regenerateOnReload: false,
86
56
  chatTextOpacity: 0,
87
57
  trajectoryOpacity: 1
88
58
  };
89
59
  const dataDir = () => dshHomePath(DATA_DIR);
90
60
  const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
91
- const wallpaperPath = () => dshHomePath(DATA_DIR, WALLPAPER_FILE);
92
- const videoPathFor = (mime) => dshHomePath(DATA_DIR, videoFileName(mime));
61
+ const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
62
+ const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
93
63
  const exists = async (p) => {
94
64
  try {
95
65
  await access(p);
@@ -98,72 +68,55 @@ const exists = async (p) => {
98
68
  return false;
99
69
  }
100
70
  };
101
- /** Locate the stored video: the recorded MIME decides the expected name; a
102
- * legacy extensionless wallpaper.video is renamed on first access. */
103
- async function findVideoFile() {
104
- const cfg = await readConfig();
105
- const expected = videoPathFor(cfg.videoMime);
106
- if (await exists(expected)) return {
107
- path: expected,
108
- mime: cfg.videoMime
109
- };
110
- for (const name of VIDEO_CANDIDATES) {
111
- const p = dshHomePath(DATA_DIR, name);
112
- if (!await exists(p)) continue;
113
- if (cfg.videoMime !== null && name !== videoFileName(cfg.videoMime)) try {
114
- await rename(p, expected);
115
- return {
116
- path: expected,
117
- mime: cfg.videoMime
118
- };
119
- } catch {
120
- return null;
121
- }
122
- return {
123
- path: p,
124
- mime: cfg.videoMime
125
- };
126
- }
127
- return null;
128
- }
129
71
  function clamp(n, lo, hi, def) {
130
72
  return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
131
73
  }
74
+ const clamp01 = (n, def) => clamp(n, 0, 1, def);
132
75
  function normalizeBgState(s) {
76
+ const v = s ?? {};
133
77
  return {
134
- zoom: clamp(s.zoom, .1, 10, 1),
135
- x: typeof s.x === "number" && isFinite(s.x) ? s.x : 0,
136
- y: typeof s.y === "number" && isFinite(s.y) ? s.y : 0,
137
- iw: typeof s.iw === "number" && s.iw > 0 ? s.iw : 0,
138
- ih: typeof s.ih === "number" && s.ih > 0 ? s.ih : 0
78
+ zoom: clamp(v.zoom, .1, 10, 1),
79
+ x: typeof v.x === "number" && isFinite(v.x) ? v.x : 0,
80
+ y: typeof v.y === "number" && isFinite(v.y) ? v.y : 0,
81
+ iw: typeof v.iw === "number" && v.iw > 0 ? v.iw : 0,
82
+ ih: typeof v.ih === "number" && v.ih > 0 ? v.ih : 0
139
83
  };
140
84
  }
141
- /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
142
- function normalizeConfig(raw) {
85
+ /** Coerce one persisted rule, or null when it lacks a usable id/slot. */
86
+ function normalizeRule(raw) {
143
87
  const r = raw ?? {};
88
+ const id = typeof r.id === "string" && r.id !== "" ? r.id : null;
89
+ const slot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
90
+ if (id === null || slot === null) return null;
144
91
  const c = r.color;
145
- const color = Array.isArray(c) && c.length === 3 && c.every((x) => typeof x === "number" && isFinite(x)) ? [
92
+ const color = Array.isArray(c) && c.length === 3 && c.every((n) => typeof n === "number" && isFinite(n)) ? [
146
93
  clamp(c[0], 0, 360, 220),
147
94
  clamp(c[1], 0, 1, .55),
148
95
  clamp(c[2], 0, 1, .25)
149
96
  ] : null;
150
- const bgType = [
151
- "image",
152
- "video",
153
- "mesh",
154
- "shader",
155
- "pattern"
156
- ].includes(r.backgroundType) ? r.backgroundType : DEFAULT_CONFIG.backgroundType;
157
- const bgMode = [
97
+ const mode = [
158
98
  "fit",
159
99
  "fill",
160
100
  "stretch",
161
101
  "tile",
162
102
  "center"
163
- ].includes(r.bgMode) ? r.bgMode : DEFAULT_CONFIG.bgMode;
164
- const gen = r.generatedBg && typeof r.generatedBg === "object" ? r.generatedBg : null;
165
- const generatedBg = gen && gen.type === bgType ? normalizeGeneratedBg(r.generatedBg) : null;
166
- const legacy = typeof r.opacity === "number" ? r.opacity : null;
103
+ ].includes(r.bgMode) ? r.bgMode : "fit";
104
+ return {
105
+ id,
106
+ slot,
107
+ match: typeof r.match === "string" ? r.match : "",
108
+ enabled: r.enabled !== false,
109
+ color,
110
+ bgMode: mode,
111
+ wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
112
+ blur: clamp(r.blur, 0, 60, 0),
113
+ bgState: normalizeBgState(r.bgState)
114
+ };
115
+ }
116
+ /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
117
+ function normalizeConfig(raw) {
118
+ const r = raw ?? {};
119
+ const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [];
167
120
  const ops = r.opacities ?? {};
168
121
  const bl = r.blurs ?? {};
169
122
  const blurs = {};
@@ -177,58 +130,69 @@ function normalizeConfig(raw) {
177
130
  "input"
178
131
  ]) blurs[k] = clamp(bl[k], 0, 60, DEFAULT_CONFIG.blurs[k]);
179
132
  return {
180
- color,
133
+ rules,
181
134
  opacities: {
182
- bg: clamp(ops.bg, 0, 1, legacy ?? DEFAULT_CONFIG.opacities.bg),
183
- sidebar: clamp(ops.sidebar, 0, 1, legacy !== null ? Math.min(1, legacy + .08) : DEFAULT_CONFIG.opacities.sidebar),
184
- card: clamp(ops.card, 0, 1, DEFAULT_CONFIG.opacities.card),
185
- input: clamp(ops.input, 0, 1, DEFAULT_CONFIG.opacities.input)
135
+ bg: clamp01(ops.bg, DEFAULT_CONFIG.opacities.bg),
136
+ sidebar: clamp01(ops.sidebar, DEFAULT_CONFIG.opacities.sidebar),
137
+ card: clamp01(ops.card, DEFAULT_CONFIG.opacities.card),
138
+ input: clamp01(ops.input, DEFAULT_CONFIG.opacities.input)
186
139
  },
187
140
  blurs,
188
- settingsOpacity: clamp(r.settingsOpacity, 0, 1, DEFAULT_CONFIG.settingsOpacity),
189
- wallpaperOpacity: clamp(r.wallpaperOpacity, 0, 1, DEFAULT_CONFIG.wallpaperOpacity),
190
- blur: clamp(r.blur, 0, 60, DEFAULT_CONFIG.blur),
191
- bgState: normalizeBgState(r.bgState ?? {}),
192
- videoBgState: normalizeBgState(r.videoBgState ?? {}),
193
- backgroundType: bgType,
194
- bgMode,
195
- videoMime: typeof r.videoMime === "string" ? r.videoMime : null,
196
- generatedBg,
197
- regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
198
- chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
199
- trajectoryOpacity: clamp(r.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity)
141
+ settingsOpacity: clamp01(r.settingsOpacity, DEFAULT_CONFIG.settingsOpacity),
142
+ chatTextOpacity: clamp01(r.chatTextOpacity, DEFAULT_CONFIG.chatTextOpacity),
143
+ trajectoryOpacity: clamp01(r.trajectoryOpacity, DEFAULT_CONFIG.trajectoryOpacity)
200
144
  };
201
145
  }
202
- function normalizeGeneratedBg(p) {
203
- if (p.type === "mesh") return {
204
- type: "mesh",
205
- seed: typeof p.seed === "number" ? p.seed : 0,
206
- scale: clamp(p.scale, .3, 3, 1),
207
- intensity: clamp(p.intensity, 0, 1, .6)
208
- };
209
- if (p.type === "shader") return {
210
- type: "shader",
211
- preset: [
212
- "aurora",
213
- "nebula",
214
- "noise"
215
- ].includes(p.preset) ? p.preset : "aurora",
216
- speed: clamp(p.speed, 0, 2, .3),
217
- scale: clamp(p.scale, .3, 3, 1),
218
- seed: typeof p.seed === "number" ? Math.floor(p.seed) : 0
219
- };
220
- if (p.type === "pattern") return {
221
- type: "pattern",
222
- preset: [
223
- "dots",
224
- "waves",
225
- "poly"
226
- ].includes(p.preset) ? p.preset : "dots",
227
- density: clamp(p.density, 0, 1, .5),
228
- scale: clamp(p.scale, .3, 3, 1),
229
- seed: typeof p.seed === "number" ? Math.floor(p.seed) : 0
146
+ /**
147
+ * One-shot migration of the pre-0.3 store.
148
+ *
149
+ * Anything without a `rules` array is an old config: its single wallpaper
150
+ * becomes rule 1 (`m1`, empty match = pure fallback) carrying the old color,
151
+ * layout mode, opacity, blur and framing, and the remaining legacy files
152
+ * (background video, upload temp) are removed. Idempotent: the rewritten config
153
+ * carries `rules`, so the next read short-circuits.
154
+ */
155
+ async function migrateLegacy(raw) {
156
+ const r = raw ?? {};
157
+ if (Array.isArray(r.rules)) return r;
158
+ const hasWallpaper = await exists(legacyWallpaperPath());
159
+ if (Object.keys(r).length === 0 && !hasWallpaper) return { rules: [] };
160
+ if (hasWallpaper) {
161
+ const target = imagePath("m1");
162
+ try {
163
+ await rm(target, { force: true });
164
+ await rename(legacyWallpaperPath(), target);
165
+ } catch (e) {
166
+ console.warn("dsh-background-by-model: could not adopt the legacy wallpaper", e);
167
+ }
168
+ }
169
+ for (const f of LEGACY_FILES) try {
170
+ await rm(dshHomePath(DATA_DIR, f), { force: true });
171
+ } catch {}
172
+ const next = {
173
+ rules: [{
174
+ id: "m1",
175
+ slot: "m1",
176
+ match: "",
177
+ enabled: true,
178
+ color: Array.isArray(r.color) ? r.color : null,
179
+ bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
180
+ wallpaperOpacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
181
+ blur: typeof r.blur === "number" ? r.blur : 0,
182
+ bgState: r.bgState ?? {}
183
+ }],
184
+ opacities: r.opacities,
185
+ blurs: r.blurs,
186
+ settingsOpacity: r.settingsOpacity,
187
+ chatTextOpacity: r.chatTextOpacity,
188
+ trajectoryOpacity: r.trajectoryOpacity
230
189
  };
231
- return null;
190
+ try {
191
+ await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
192
+ } catch (e) {
193
+ console.warn("dsh-background-by-model: could not rewrite the migrated config", e);
194
+ }
195
+ return next;
232
196
  }
233
197
  async function ensureDir() {
234
198
  try {
@@ -239,12 +203,13 @@ async function ensureDir() {
239
203
  }
240
204
  async function readConfig() {
241
205
  await ensureDir();
206
+ let raw = {};
242
207
  try {
243
- const raw = await readFile(configPath(), "utf8");
244
- return normalizeConfig(JSON.parse(raw));
208
+ raw = JSON.parse(await readFile(configPath(), "utf8"));
245
209
  } catch {
246
- return { ...DEFAULT_CONFIG };
210
+ raw = {};
247
211
  }
212
+ return normalizeConfig(await migrateLegacy(raw));
248
213
  }
249
214
  async function writeConfig(config) {
250
215
  await ensureDir();
@@ -256,61 +221,72 @@ async function writeConfig(config) {
256
221
  return false;
257
222
  }
258
223
  }
259
- async function readWallpaper() {
224
+ /** Sniff an image's MIME from its leading magic bytes (defaults to JPEG). */
225
+ function sniffImageMime(buf) {
226
+ if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
227
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
228
+ if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
229
+ if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return "image/webp";
230
+ if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) return "image/bmp";
231
+ return "image/jpeg";
232
+ }
233
+ /** Read one rule image as a data URL, or null when the slot is empty. */
234
+ async function readImage(slot) {
260
235
  try {
261
- return `data:image/jpeg;base64,${(await readFile(wallpaperPath())).toString("base64")}`;
236
+ const buf = await readFile(imagePath(slot));
237
+ return `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
262
238
  } catch {
263
239
  return null;
264
240
  }
265
241
  }
266
- /** Persist a wallpaper (null removes it); false keeps the previous file. */
267
- async function writeWallpaper(dataUrl) {
242
+ /** Persist one rule image (null removes it); false keeps the previous file. */
243
+ async function writeImage(slot, dataUrl) {
268
244
  await ensureDir();
269
245
  try {
270
246
  if (dataUrl === null) {
271
- await rm(wallpaperPath(), { force: true });
247
+ await rm(imagePath(slot), { force: true });
272
248
  return true;
273
249
  }
274
250
  const m = /^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
275
251
  if (!m) return false;
276
- await writeFile(wallpaperPath(), Buffer.from(m[1], "base64"));
252
+ await writeFile(imagePath(slot), Buffer.from(m[1], "base64"));
277
253
  return true;
278
254
  } catch (e) {
279
- console.error(`dsh-background-by-model: failed to write "${WALLPAPER_FILE}"`, e);
255
+ console.error(`dsh-background-by-model: failed to write the image for slot "${slot}"`, e);
280
256
  return false;
281
257
  }
282
258
  }
283
- /** Sniff an image's MIME from its leading magic bytes (defaults to JPEG). */
284
- function sniffImageMime(buf) {
285
- if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
286
- if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
287
- if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
288
- if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return "image/webp";
289
- return "image/jpeg";
259
+ /** Every image slot currently stored on disk. */
260
+ async function listSlots() {
261
+ try {
262
+ return (await readdir(dataDir())).filter((name) => name.startsWith(IMAGE_PREFIX)).map((name) => name.slice(8)).filter((slot) => SLOT_RE.test(slot));
263
+ } catch {
264
+ return [];
265
+ }
290
266
  }
291
- /** Download a wallpaper from a network URL and persist it into the local
292
- * wallpaper.jpg slot (replacing whatever was stored), so type switches,
293
- * export and import keep working through the existing data-URL path.
294
- * null removes the wallpaper. Returns { ok, dataUrl?, error? }. */
295
- async function writeWallpaperFromUrl(url) {
267
+ /**
268
+ * Download an image from a network URL into one slot (replacing its bytes).
269
+ * Returns { ok, dataUrl?, error? }; never throws.
270
+ */
271
+ async function fetchImageUrl(slot, url) {
296
272
  if (url === null) {
297
- const ok = await writeWallpaper(null);
273
+ const ok = await writeImage(slot, null);
298
274
  return {
299
275
  ok,
300
276
  dataUrl: null,
301
277
  error: ok ? void 0 : "remove failed"
302
278
  };
303
279
  }
304
- let u;
280
+ let parsed;
305
281
  try {
306
- u = new URL(url);
282
+ parsed = new URL(url);
307
283
  } catch {
308
284
  return {
309
285
  ok: false,
310
286
  error: "invalid url"
311
287
  };
312
288
  }
313
- if (u.protocol !== "http:" && u.protocol !== "https:") return {
289
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
314
290
  ok: false,
315
291
  error: "unsupported scheme"
316
292
  };
@@ -360,7 +336,7 @@ async function writeWallpaperFromUrl(url) {
360
336
  };
361
337
  }
362
338
  const dataUrl = `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
363
- return await writeWallpaper(dataUrl) ? {
339
+ return await writeImage(slot, dataUrl) ? {
364
340
  ok: true,
365
341
  dataUrl
366
342
  } : {
@@ -368,206 +344,82 @@ async function writeWallpaperFromUrl(url) {
368
344
  error: "write failed"
369
345
  };
370
346
  }
371
- async function videoUrl() {
372
- return await findVideoFile() ? VIDEO_ROUTE : null;
373
- }
374
- /** Persist a video from a data URL (null removes every variant); only used
375
- * for removal and small legacy/import payloads. */
376
- async function writeVideo(dataUrl) {
377
- await ensureDir();
378
- try {
379
- if (dataUrl === null) {
380
- for (const name of VIDEO_CANDIDATES) await rm(dshHomePath(DATA_DIR, name), { force: true });
381
- return true;
382
- }
383
- const m = /^data:(video\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
384
- if (!m) return false;
385
- const target = videoPathFor(m[1]);
386
- for (const name of VIDEO_CANDIDATES) {
387
- const p = dshHomePath(DATA_DIR, name);
388
- if (p !== target) await rm(p, { force: true });
389
- }
390
- await writeFile(target, Buffer.from(m[2], "base64"));
391
- return true;
392
- } catch (e) {
393
- console.error("dsh-background-by-model: failed to write the background video", e);
394
- return false;
395
- }
396
- }
397
- /** Stream the stored video: correct MIME, no caching, Range answers so the
398
- * browser can seek (snapshot capture does). */
399
- async function serveVideo(req, res) {
400
- if (req.method !== "GET" && req.method !== "HEAD") {
401
- res.writeHead(405, { "Content-Type": "application/json" });
402
- res.end(JSON.stringify({
403
- ok: false,
404
- error: "video route only serves GET/HEAD; uploads need the plugin upload route — restart the web server to load it"
405
- }));
406
- return;
407
- }
408
- try {
409
- const found = await findVideoFile();
410
- if (found === null) {
411
- res.writeHead(404);
412
- res.end("no background video stored");
413
- return;
414
- }
415
- const st = await stat(found.path);
416
- const baseHeaders = {
417
- "Content-Type": found.mime ?? "application/octet-stream",
418
- "Accept-Ranges": "bytes",
419
- "Cache-Control": "no-store"
420
- };
421
- const range = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
422
- const m = /^bytes=(\d*)-(\d*)$/.exec(range);
423
- if (m !== null && (m[1] !== "" || m[2] !== "")) {
424
- let start;
425
- let end;
426
- if (m[1] === "") {
427
- const suffix = parseInt(m[2], 10);
428
- start = Math.max(0, st.size - suffix);
429
- end = st.size - 1;
430
- } else {
431
- start = parseInt(m[1], 10);
432
- end = m[2] !== "" ? Math.min(parseInt(m[2], 10), st.size - 1) : st.size - 1;
433
- }
434
- if (start >= st.size || start > end) {
435
- res.writeHead(416, { "Content-Range": `bytes */${st.size}` });
436
- res.end();
437
- return;
438
- }
439
- res.writeHead(206, {
440
- ...baseHeaders,
441
- "Content-Range": `bytes ${start}-${end}/${st.size}`,
442
- "Content-Length": end - start + 1
443
- });
444
- if (req.method === "HEAD") {
445
- res.end();
446
- return;
447
- }
448
- createReadStream(found.path, {
449
- start,
450
- end
451
- }).pipe(res);
452
- return;
453
- }
454
- res.writeHead(200, {
455
- ...baseHeaders,
456
- "Content-Length": st.size
457
- });
458
- if (req.method === "HEAD") {
459
- res.end();
460
- return;
461
- }
462
- createReadStream(found.path).pipe(res);
463
- } catch (e) {
464
- console.error("dsh-background-by-model: failed to serve the background video", e);
465
- try {
466
- res.writeHead(500);
467
- res.end();
468
- } catch {}
469
- }
470
- }
471
- /** Accept a raw video upload (POST): pipe the body into a temp file, then
472
- * rename it into the MIME-derived slot. Aborted transfers clean up. */
473
- async function handleVideoUpload(req, res) {
474
- if (req.method !== "POST") {
475
- res.writeHead(405);
476
- res.end();
477
- return;
478
- }
479
- const mime = (typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "").split(";")[0].trim();
480
- if (!mime.startsWith("video/")) {
481
- req.resume();
482
- res.writeHead(415, { "Content-Type": "application/json" });
483
- res.end(JSON.stringify({
484
- ok: false,
485
- error: "unsupported media type, expected video/*"
486
- }));
487
- return;
488
- }
347
+ /** The host's default model selection — the client's fallback when the
348
+ * per-session model services are unavailable. */
349
+ function defaultModel(ctx) {
489
350
  try {
490
- await ensureDir();
491
- const tmp = dshHomePath(DATA_DIR, UPLOAD_TMP);
492
- const target = videoPathFor(mime);
493
- const out = createWriteStream(tmp);
494
- let failed = false;
495
- const fail = () => {
496
- if (failed) return;
497
- failed = true;
498
- out.destroy();
499
- rm(tmp, { force: true });
351
+ const selection = (typeof ctx.get === "function" ? ctx.get("agentDefaultModel") : void 0)?.currentSelection?.();
352
+ if (selection && typeof selection.provider === "string" && typeof selection.model === "string") return {
353
+ provider: selection.provider,
354
+ model: selection.model
500
355
  };
501
- req.on("aborted", fail);
502
- req.on("error", fail);
503
- out.on("error", () => {
504
- fail();
505
- try {
506
- res.writeHead(500);
507
- res.end();
508
- } catch {}
509
- });
510
- req.pipe(out);
511
- out.on("finish", async () => {
512
- if (failed) return;
513
- try {
514
- for (const name of VIDEO_CANDIDATES) {
515
- const p = dshHomePath(DATA_DIR, name);
516
- if (p !== target) await rm(p, { force: true });
517
- }
518
- await rm(target, { force: true });
519
- await rename(tmp, target);
520
- res.writeHead(200, { "Content-Type": "application/json" });
521
- res.end(JSON.stringify({ ok: true }));
522
- } catch (e) {
523
- console.error("dsh-background-by-model: failed to finalize the uploaded video", e);
524
- rm(tmp, { force: true });
525
- try {
526
- res.writeHead(500);
527
- res.end();
528
- } catch {}
529
- }
530
- });
531
- } catch (e) {
532
- console.error("dsh-background-by-model: failed to accept the video upload", e);
533
- try {
534
- res.writeHead(500);
535
- res.end();
536
- } catch {}
537
- }
356
+ } catch {}
357
+ return null;
538
358
  }
539
359
  const NS = "dshBackgroundByModel";
540
360
  const RPC_CHANNEL = "/dsh-background-by-model";
541
361
  const RPC_BODY_MAX = 314572800;
542
362
  /** Dispatch one decoded RPC method to the matching persistence routine and
543
363
  * return the wire `result` half of the server-response envelope. */
544
- async function handleRpcMethod(endpoint, payload) {
364
+ async function handleRpcMethod(ctx, endpoint, payload) {
545
365
  const method = endpoint.slice(`${NS}/`.length);
366
+ const rawSlot = payload?.slot;
367
+ const slot = typeof rawSlot === "string" && SLOT_RE.test(rawSlot) ? rawSlot : null;
546
368
  try {
547
369
  switch (method) {
548
370
  case "read": return {
549
371
  ok: true,
550
372
  value: {
551
373
  config: await readConfig(),
552
- wallpaper: await readWallpaper(),
553
- videoUrl: await videoUrl()
374
+ slots: await listSlots()
554
375
  }
555
376
  };
556
377
  case "writeConfig": return {
557
378
  ok: true,
558
379
  value: await writeConfig(payload?.config ?? {})
559
380
  };
560
- case "setWallpaper": return {
561
- ok: true,
562
- value: await writeWallpaper(payload?.dataUrl ?? null)
563
- };
564
- case "setVideo": return {
565
- ok: true,
566
- value: await writeVideo(payload?.dataUrl ?? null)
567
- };
568
- case "setWallpaperUrl": return {
381
+ case "readImage":
382
+ if (slot === null) return {
383
+ ok: true,
384
+ value: { dataUrl: null }
385
+ };
386
+ return {
387
+ ok: true,
388
+ value: { dataUrl: await readImage(slot) }
389
+ };
390
+ case "writeImage":
391
+ if (slot === null) return {
392
+ ok: true,
393
+ value: false
394
+ };
395
+ return {
396
+ ok: true,
397
+ value: await writeImage(slot, payload?.dataUrl ?? null)
398
+ };
399
+ case "deleteImage":
400
+ if (slot === null) return {
401
+ ok: true,
402
+ value: false
403
+ };
404
+ return {
405
+ ok: true,
406
+ value: await writeImage(slot, null)
407
+ };
408
+ case "fetchImageUrl":
409
+ if (slot === null) return {
410
+ ok: true,
411
+ value: {
412
+ ok: false,
413
+ error: "bad slot"
414
+ }
415
+ };
416
+ return {
417
+ ok: true,
418
+ value: await fetchImageUrl(slot, payload?.url ?? null)
419
+ };
420
+ case "defaultModel": return {
569
421
  ok: true,
570
- value: await writeWallpaperFromUrl(payload?.url ?? null)
422
+ value: defaultModel(ctx)
571
423
  };
572
424
  default: return {
573
425
  ok: false,
@@ -676,7 +528,7 @@ function apply(ctx) {
676
528
  }));
677
529
  return;
678
530
  }
679
- const result = await handleRpcMethod(endpoint, env.payload);
531
+ const result = await handleRpcMethod(ctx, endpoint, env.payload);
680
532
  res.writeHead(200, { "Content-Type": "application/json" });
681
533
  res.end(JSON.stringify({
682
534
  type: "server-response",
@@ -685,16 +537,6 @@ function apply(ctx) {
685
537
  }));
686
538
  }
687
539
  }), "dsh-background-by-model: rpc channel");
688
- webCtx.effect(() => webCtx.webServer.register({
689
- kind: "prefix",
690
- path: VIDEO_ROUTE,
691
- handler: serveVideo
692
- }), "dsh-background-by-model: video route");
693
- webCtx.effect(() => webCtx.webServer.register({
694
- kind: "exact",
695
- path: UPLOAD_ROUTE,
696
- handler: handleVideoUpload
697
- }), "dsh-background-by-model: upload route");
698
540
  });
699
541
  }
700
542
  //#endregion