dsh-background-by-model 0.2.4
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/LICENSE +21 -0
- package/README.md +142 -0
- package/README.zh.md +142 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +5498 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +701 -0
- package/lib/invariant.js +8 -0
- package/package.json +121 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* Node half of dsh-background-by-model: file-backed theme persistence.
|
|
7
|
+
*
|
|
8
|
+
* Owns the `~/.dsh/.dsh-background-by-model-data/` store and exposes a small RPC
|
|
9
|
+
* surface on the dedicated `/dsh-background-by-model` channel (never the shared
|
|
10
|
+
* `/api`, so slash commands stay intact).
|
|
11
|
+
*
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
const name = "dsh-background-by-model";
|
|
20
|
+
const inject = ["connection", "webServer"];
|
|
21
|
+
const DATA_DIR = ".dsh-background-by-model-data";
|
|
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 = [
|
|
40
|
+
"wallpaper.mp4",
|
|
41
|
+
"wallpaper.webm",
|
|
42
|
+
"wallpaper.ogv",
|
|
43
|
+
"wallpaper.mov",
|
|
44
|
+
"wallpaper.mkv",
|
|
45
|
+
"wallpaper.video"
|
|
46
|
+
];
|
|
47
|
+
const DEFAULT_CONFIG = {
|
|
48
|
+
color: null,
|
|
49
|
+
opacities: {
|
|
50
|
+
bg: .85,
|
|
51
|
+
sidebar: .93,
|
|
52
|
+
card: 1,
|
|
53
|
+
input: 1
|
|
54
|
+
},
|
|
55
|
+
blurs: {
|
|
56
|
+
bg: 0,
|
|
57
|
+
sidebar: 0,
|
|
58
|
+
card: 0,
|
|
59
|
+
settings: 0,
|
|
60
|
+
chat: 0,
|
|
61
|
+
trajectory: 0,
|
|
62
|
+
input: 0
|
|
63
|
+
},
|
|
64
|
+
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
|
+
chatTextOpacity: 0,
|
|
87
|
+
trajectoryOpacity: 1
|
|
88
|
+
};
|
|
89
|
+
const dataDir = () => dshHomePath(DATA_DIR);
|
|
90
|
+
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
91
|
+
const wallpaperPath = () => dshHomePath(DATA_DIR, WALLPAPER_FILE);
|
|
92
|
+
const videoPathFor = (mime) => dshHomePath(DATA_DIR, videoFileName(mime));
|
|
93
|
+
const exists = async (p) => {
|
|
94
|
+
try {
|
|
95
|
+
await access(p);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
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
|
+
function clamp(n, lo, hi, def) {
|
|
130
|
+
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
131
|
+
}
|
|
132
|
+
function normalizeBgState(s) {
|
|
133
|
+
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
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
|
|
142
|
+
function normalizeConfig(raw) {
|
|
143
|
+
const r = raw ?? {};
|
|
144
|
+
const c = r.color;
|
|
145
|
+
const color = Array.isArray(c) && c.length === 3 && c.every((x) => typeof x === "number" && isFinite(x)) ? [
|
|
146
|
+
clamp(c[0], 0, 360, 220),
|
|
147
|
+
clamp(c[1], 0, 1, .55),
|
|
148
|
+
clamp(c[2], 0, 1, .25)
|
|
149
|
+
] : 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 = [
|
|
158
|
+
"fit",
|
|
159
|
+
"fill",
|
|
160
|
+
"stretch",
|
|
161
|
+
"tile",
|
|
162
|
+
"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;
|
|
167
|
+
const ops = r.opacities ?? {};
|
|
168
|
+
const bl = r.blurs ?? {};
|
|
169
|
+
const blurs = {};
|
|
170
|
+
for (const k of [
|
|
171
|
+
"bg",
|
|
172
|
+
"sidebar",
|
|
173
|
+
"card",
|
|
174
|
+
"settings",
|
|
175
|
+
"chat",
|
|
176
|
+
"trajectory",
|
|
177
|
+
"input"
|
|
178
|
+
]) blurs[k] = clamp(bl[k], 0, 60, DEFAULT_CONFIG.blurs[k]);
|
|
179
|
+
return {
|
|
180
|
+
color,
|
|
181
|
+
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)
|
|
186
|
+
},
|
|
187
|
+
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)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
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
|
|
230
|
+
};
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
async function ensureDir() {
|
|
234
|
+
try {
|
|
235
|
+
await mkdir(dataDir(), { recursive: true });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn(`dsh-background-by-model: cannot create data dir "${dataDir()}"`, e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function readConfig() {
|
|
241
|
+
await ensureDir();
|
|
242
|
+
try {
|
|
243
|
+
const raw = await readFile(configPath(), "utf8");
|
|
244
|
+
return normalizeConfig(JSON.parse(raw));
|
|
245
|
+
} catch {
|
|
246
|
+
return { ...DEFAULT_CONFIG };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function writeConfig(config) {
|
|
250
|
+
await ensureDir();
|
|
251
|
+
try {
|
|
252
|
+
await writeFile(configPath(), JSON.stringify(normalizeConfig(config), null, 2), "utf8");
|
|
253
|
+
return true;
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error(`dsh-background-by-model: failed to write "${CONFIG_FILE}"`, e);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function readWallpaper() {
|
|
260
|
+
try {
|
|
261
|
+
return `data:image/jpeg;base64,${(await readFile(wallpaperPath())).toString("base64")}`;
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Persist a wallpaper (null removes it); false keeps the previous file. */
|
|
267
|
+
async function writeWallpaper(dataUrl) {
|
|
268
|
+
await ensureDir();
|
|
269
|
+
try {
|
|
270
|
+
if (dataUrl === null) {
|
|
271
|
+
await rm(wallpaperPath(), { force: true });
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
const m = /^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
275
|
+
if (!m) return false;
|
|
276
|
+
await writeFile(wallpaperPath(), Buffer.from(m[1], "base64"));
|
|
277
|
+
return true;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
console.error(`dsh-background-by-model: failed to write "${WALLPAPER_FILE}"`, e);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
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";
|
|
290
|
+
}
|
|
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) {
|
|
296
|
+
if (url === null) {
|
|
297
|
+
const ok = await writeWallpaper(null);
|
|
298
|
+
return {
|
|
299
|
+
ok,
|
|
300
|
+
dataUrl: null,
|
|
301
|
+
error: ok ? void 0 : "remove failed"
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
let u;
|
|
305
|
+
try {
|
|
306
|
+
u = new URL(url);
|
|
307
|
+
} catch {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
error: "invalid url"
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return {
|
|
314
|
+
ok: false,
|
|
315
|
+
error: "unsupported scheme"
|
|
316
|
+
};
|
|
317
|
+
let res;
|
|
318
|
+
try {
|
|
319
|
+
const ctl = new AbortController();
|
|
320
|
+
const timer = setTimeout(() => ctl.abort(), WALLPAPER_FETCH_TIMEOUT);
|
|
321
|
+
try {
|
|
322
|
+
res = await fetch(url, {
|
|
323
|
+
redirect: "follow",
|
|
324
|
+
signal: ctl.signal
|
|
325
|
+
});
|
|
326
|
+
} finally {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
error: e instanceof Error && e.name === "AbortError" ? "timeout" : "network error"
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (!res.ok) return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: `http ${res.status}`
|
|
338
|
+
};
|
|
339
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
340
|
+
if (ct && !/^image\//.test(ct)) return {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: "not an image"
|
|
343
|
+
};
|
|
344
|
+
let buf;
|
|
345
|
+
try {
|
|
346
|
+
const arr = await res.arrayBuffer();
|
|
347
|
+
if (arr.byteLength === 0) return {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: "empty response"
|
|
350
|
+
};
|
|
351
|
+
if (arr.byteLength > WALLPAPER_FETCH_MAX) return {
|
|
352
|
+
ok: false,
|
|
353
|
+
error: "too large"
|
|
354
|
+
};
|
|
355
|
+
buf = Buffer.from(arr);
|
|
356
|
+
} catch {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: "read failed"
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const dataUrl = `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
363
|
+
return await writeWallpaper(dataUrl) ? {
|
|
364
|
+
ok: true,
|
|
365
|
+
dataUrl
|
|
366
|
+
} : {
|
|
367
|
+
ok: false,
|
|
368
|
+
error: "write failed"
|
|
369
|
+
};
|
|
370
|
+
}
|
|
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
|
+
}
|
|
489
|
+
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 });
|
|
500
|
+
};
|
|
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
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const NS = "dshBackgroundByModel";
|
|
540
|
+
const RPC_CHANNEL = "/dsh-background-by-model";
|
|
541
|
+
const RPC_BODY_MAX = 314572800;
|
|
542
|
+
/** Dispatch one decoded RPC method to the matching persistence routine and
|
|
543
|
+
* return the wire `result` half of the server-response envelope. */
|
|
544
|
+
async function handleRpcMethod(endpoint, payload) {
|
|
545
|
+
const method = endpoint.slice(`${NS}/`.length);
|
|
546
|
+
try {
|
|
547
|
+
switch (method) {
|
|
548
|
+
case "read": return {
|
|
549
|
+
ok: true,
|
|
550
|
+
value: {
|
|
551
|
+
config: await readConfig(),
|
|
552
|
+
wallpaper: await readWallpaper(),
|
|
553
|
+
videoUrl: await videoUrl()
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
case "writeConfig": return {
|
|
557
|
+
ok: true,
|
|
558
|
+
value: await writeConfig(payload?.config ?? {})
|
|
559
|
+
};
|
|
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 {
|
|
569
|
+
ok: true,
|
|
570
|
+
value: await writeWallpaperFromUrl(payload?.url ?? null)
|
|
571
|
+
};
|
|
572
|
+
default: return {
|
|
573
|
+
ok: false,
|
|
574
|
+
error: {
|
|
575
|
+
code: "dsh-background-by-model/bad-request",
|
|
576
|
+
message: `unknown endpoint ${endpoint}`,
|
|
577
|
+
details: { issues: [] }
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
error: {
|
|
585
|
+
code: "dsh-background-by-model/internal",
|
|
586
|
+
message: e instanceof Error ? e.message : String(e),
|
|
587
|
+
details: {}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function apply(ctx) {
|
|
593
|
+
ctx.inject(["connection", "webServer"], (webCtx) => {
|
|
594
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
595
|
+
kind: "prefix",
|
|
596
|
+
path: RPC_CHANNEL,
|
|
597
|
+
handler: async (req, res) => {
|
|
598
|
+
const rejection = webCtx.connection.requestRejection(req);
|
|
599
|
+
if (rejection !== void 0) {
|
|
600
|
+
res.writeHead(rejection);
|
|
601
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (req.method !== "POST") {
|
|
605
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
606
|
+
res.end(JSON.stringify({
|
|
607
|
+
ok: false,
|
|
608
|
+
error: {
|
|
609
|
+
code: "dsh-background-by-model/bad-request",
|
|
610
|
+
message: "expected POST",
|
|
611
|
+
details: {}
|
|
612
|
+
}
|
|
613
|
+
}));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
617
|
+
const endpoint = pathname.startsWith(`${RPC_CHANNEL}/`) ? pathname.slice(25) : void 0;
|
|
618
|
+
if (endpoint === void 0 || endpoint.length === 0) {
|
|
619
|
+
res.writeHead(404);
|
|
620
|
+
res.end();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const chunks = [];
|
|
624
|
+
let received = 0;
|
|
625
|
+
for await (const chunk of req) {
|
|
626
|
+
const buf = chunk;
|
|
627
|
+
received += buf.byteLength;
|
|
628
|
+
if (received > RPC_BODY_MAX) {
|
|
629
|
+
res.writeHead(413, { connection: "close" });
|
|
630
|
+
res.end();
|
|
631
|
+
req.destroy();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
chunks.push(buf);
|
|
635
|
+
}
|
|
636
|
+
let env;
|
|
637
|
+
try {
|
|
638
|
+
env = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
639
|
+
} catch {
|
|
640
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
641
|
+
res.end(JSON.stringify({
|
|
642
|
+
ok: false,
|
|
643
|
+
error: {
|
|
644
|
+
code: "dsh-background-by-model/bad-request",
|
|
645
|
+
message: "body is not JSON",
|
|
646
|
+
details: {}
|
|
647
|
+
}
|
|
648
|
+
}));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (env === null || typeof env !== "object" || env.type !== "client-request" || typeof env.rpcId !== "string" || typeof env.method !== "string") {
|
|
652
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
653
|
+
res.end(JSON.stringify({
|
|
654
|
+
ok: false,
|
|
655
|
+
error: {
|
|
656
|
+
code: "dsh-background-by-model/bad-request",
|
|
657
|
+
message: "invalid client-request envelope",
|
|
658
|
+
details: {}
|
|
659
|
+
}
|
|
660
|
+
}));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (env.method !== endpoint) {
|
|
664
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
665
|
+
res.end(JSON.stringify({
|
|
666
|
+
type: "server-response",
|
|
667
|
+
rpcId: env.rpcId,
|
|
668
|
+
result: {
|
|
669
|
+
ok: false,
|
|
670
|
+
error: {
|
|
671
|
+
code: "dsh-background-by-model/bad-request",
|
|
672
|
+
message: `method ${env.method} does not match endpoint ${endpoint}`,
|
|
673
|
+
details: { issues: [] }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const result = await handleRpcMethod(endpoint, env.payload);
|
|
680
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
681
|
+
res.end(JSON.stringify({
|
|
682
|
+
type: "server-response",
|
|
683
|
+
rpcId: env.rpcId,
|
|
684
|
+
result
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
}), "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
|
+
});
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
export { apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/invariant.ts
|
|
2
|
+
const PACKAGE_NAME = "dsh-background-by-model";
|
|
3
|
+
const name = "dsh-background-by-model-invariant";
|
|
4
|
+
const inject = ["invariants"];
|
|
5
|
+
const install = () => {};
|
|
6
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
7
|
+
//#endregion
|
|
8
|
+
export { apply, inject, name };
|