beauticode-dsh 1.0.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/README.zh-CN.md +48 -0
- package/agent.mjs +595 -0
- package/atmosphere.js +306 -0
- package/bin/beauticode-dsh +219 -0
- package/cli.js +219 -0
- package/client.js +370 -0
- package/console.js +303 -0
- package/control-client.mjs +445 -0
- package/cordis.patch.yml +4 -0
- package/host-apply.mjs +163 -0
- package/index.mjs +570 -0
- package/package.json +56 -0
- package/presets.mjs +73 -0
- package/themes/internal-beyond/NOTICE.md +9 -0
- package/themes/internal-beyond/bg-canvas-4k.png +0 -0
- package/themes/internal-beyond/bg-canvas.png +0 -0
- package/themes/internal-beyond/bg-infernal.jpg +0 -0
- package/themes/internal-beyond/bg-internal.jpg +0 -0
- package/ui-host.mjs +292 -0
- package/vendor/adapter-dsh/bridge.js +215 -0
- package/vendor/adapter-dsh/host-descriptor.js +15 -0
- package/vendor/adapter-dsh/index.js +6 -0
- package/vendor/adapter-dsh/injector-lock.js +27 -0
- package/vendor/adapter-dsh/package.json +5 -0
- package/vendor/adapter-dsh/session.js +527 -0
- package/vendor/adapter-dsh/token.js +46 -0
- package/vendor/adapter-dsh/tray-handoff.js +54 -0
- package/vendor/core/apply-transaction.js +287 -0
- package/vendor/core/background-store.js +1116 -0
- package/vendor/core/bundled-gallery.js +53 -0
- package/vendor/core/constants.js +47 -0
- package/vendor/core/error-message.js +208 -0
- package/vendor/core/file-lock.js +151 -0
- package/vendor/core/host-session.js +2 -0
- package/vendor/core/index.js +12 -0
- package/vendor/core/media-server.js +544 -0
- package/vendor/core/media-validation.js +244 -0
- package/vendor/core/package.json +5 -0
- package/vendor/core/paths.js +168 -0
- package/vendor/core/types.js +15 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { COMMIT_MARKER_NAME, COMMIT_MARKER_STALE_MS, DEFAULT_VIDEO_BASENAME, MANIFEST_NAME, SAVED_META_NAME, SCHEMA_ID, } from "./constants.js";
|
|
6
|
+
import { assertSafeBasename, MediaValidationError, validateImageFile, validateVideoFile, } from "./media-validation.js";
|
|
7
|
+
import { copyFileAtomic, emptyDir, ensureDataLayout, isPathInsideRoot, linkOrCopyFileAtomic, resolveDataPaths, rmrf, } from "./paths.js";
|
|
8
|
+
import { normalizeBackgroundEffects } from "./types.js";
|
|
9
|
+
import { BUNDLED_GALLERY_THEME_ID, } from "./bundled-gallery.js";
|
|
10
|
+
import { acquireFileLock } from "./file-lock.js";
|
|
11
|
+
/** 1×1 transparent PNG — poster when video apply has no prior image. */
|
|
12
|
+
const SYNTHETIC_POSTER_PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082", "hex");
|
|
13
|
+
const COMMIT_JOURNAL_NAME = ".beauticode-commit.json";
|
|
14
|
+
function nowIso() {
|
|
15
|
+
return new Date().toISOString();
|
|
16
|
+
}
|
|
17
|
+
function emptyManifest(generation = 0) {
|
|
18
|
+
return {
|
|
19
|
+
schema: SCHEMA_ID,
|
|
20
|
+
generation,
|
|
21
|
+
background: null,
|
|
22
|
+
updatedAt: nowIso(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function isManifest(value) {
|
|
26
|
+
if (!value || typeof value !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
const v = value;
|
|
29
|
+
if (v.schema !== SCHEMA_ID)
|
|
30
|
+
return false;
|
|
31
|
+
if (typeof v.generation !== "number" || !Number.isInteger(v.generation) || v.generation < 0) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (typeof v.updatedAt !== "string")
|
|
35
|
+
return false;
|
|
36
|
+
if (v.background === null)
|
|
37
|
+
return true;
|
|
38
|
+
if (!v.background || typeof v.background !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
const b = v.background;
|
|
41
|
+
if (b.type !== "image" && b.type !== "video")
|
|
42
|
+
return false;
|
|
43
|
+
if (typeof b.image !== "string")
|
|
44
|
+
return false;
|
|
45
|
+
if (b.type === "video" && typeof b.video !== "string")
|
|
46
|
+
return false;
|
|
47
|
+
if (b.effects != null && !normalizeBackgroundEffects(b.effects))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
export class BackgroundStore {
|
|
52
|
+
paths;
|
|
53
|
+
commitMarkerStaleMs;
|
|
54
|
+
maxSavedThemes;
|
|
55
|
+
maxSavedBytes;
|
|
56
|
+
bundledThemes;
|
|
57
|
+
#runtimeSessionName = `session-${process.pid}-${crypto.randomUUID()}`;
|
|
58
|
+
#runtimeVideoCache = new Map();
|
|
59
|
+
#writeChain = Promise.resolve();
|
|
60
|
+
#writeContext = new AsyncLocalStorage();
|
|
61
|
+
constructor(opts = {}) {
|
|
62
|
+
this.paths = resolveDataPaths(opts.root);
|
|
63
|
+
this.commitMarkerStaleMs = opts.commitMarkerStaleMs ?? COMMIT_MARKER_STALE_MS;
|
|
64
|
+
this.maxSavedThemes = opts.maxSavedThemes ?? 20;
|
|
65
|
+
this.maxSavedBytes = opts.maxSavedBytes ?? 2 * 1024 * 1024 * 1024;
|
|
66
|
+
this.bundledThemes = normalizeBundledThemes(opts.bundledThemes);
|
|
67
|
+
}
|
|
68
|
+
async init() {
|
|
69
|
+
await ensureDataLayout(this.paths);
|
|
70
|
+
const markerFresh = await this.#isCommitMarkerFresh();
|
|
71
|
+
if (!markerFresh || this.#writeContext.getStore()) {
|
|
72
|
+
await this.#recoverInterruptedCommit();
|
|
73
|
+
await fs.mkdir(this.paths.activeDir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const manifestPath = path.join(this.paths.activeDir, MANIFEST_NAME);
|
|
79
|
+
try {
|
|
80
|
+
await fs.access(manifestPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
await this.#writeJsonAtomic(manifestPath, emptyManifest(0));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
#markerPath() {
|
|
87
|
+
return path.join(this.paths.root, COMMIT_MARKER_NAME);
|
|
88
|
+
}
|
|
89
|
+
#legacyMarkerPath() {
|
|
90
|
+
return path.join(this.paths.activeDir, COMMIT_MARKER_NAME);
|
|
91
|
+
}
|
|
92
|
+
async #isCommitMarkerFresh() {
|
|
93
|
+
for (const marker of [this.#markerPath(), this.#legacyMarkerPath()]) {
|
|
94
|
+
try {
|
|
95
|
+
const st = await fs.stat(marker);
|
|
96
|
+
if (Date.now() - st.mtimeMs <= this.commitMarkerStaleMs)
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* absent */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
async #assertNotCommitting() {
|
|
106
|
+
if (await this.#isCommitMarkerFresh()) {
|
|
107
|
+
throw new MediaValidationError("Background store commit in progress; refusing half-written tree.");
|
|
108
|
+
}
|
|
109
|
+
await this.#recoverInterruptedCommit();
|
|
110
|
+
}
|
|
111
|
+
async #writeJsonAtomic(filePath, data) {
|
|
112
|
+
const dir = path.dirname(filePath);
|
|
113
|
+
await fs.mkdir(dir, { recursive: true });
|
|
114
|
+
const tmp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
115
|
+
await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
116
|
+
await fs.rename(tmp, filePath);
|
|
117
|
+
}
|
|
118
|
+
#journalPath() {
|
|
119
|
+
return path.join(this.paths.root, COMMIT_JOURNAL_NAME);
|
|
120
|
+
}
|
|
121
|
+
async #hasValidManifest(dir) {
|
|
122
|
+
try {
|
|
123
|
+
const raw = await fs.readFile(path.join(dir, MANIFEST_NAME), "utf8");
|
|
124
|
+
return isManifest(JSON.parse(raw));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async #recoverInterruptedCommit() {
|
|
131
|
+
let journal = null;
|
|
132
|
+
try {
|
|
133
|
+
const raw = await fs.readFile(this.#journalPath(), "utf8");
|
|
134
|
+
const value = JSON.parse(raw);
|
|
135
|
+
if (value.version === 1 &&
|
|
136
|
+
(value.phase === "prepared" ||
|
|
137
|
+
value.phase === "old-moved" ||
|
|
138
|
+
value.phase === "new-active") &&
|
|
139
|
+
typeof value.nextDir === "string" &&
|
|
140
|
+
/^tx-[A-Za-z0-9._-]+$/.test(value.nextDir) &&
|
|
141
|
+
typeof value.backupDir === "string" &&
|
|
142
|
+
/^active-backup-[A-Za-z0-9._-]+$/.test(value.backupDir)) {
|
|
143
|
+
journal = value;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* no journal */
|
|
148
|
+
}
|
|
149
|
+
if (!journal) {
|
|
150
|
+
await fs.rm(this.#journalPath(), { force: true }).catch(() => { });
|
|
151
|
+
await fs.rm(this.#markerPath(), { force: true }).catch(() => { });
|
|
152
|
+
await fs.rm(this.#legacyMarkerPath(), { force: true }).catch(() => { });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const nextDir = path.join(this.paths.stagingDir, journal.nextDir);
|
|
156
|
+
const backupDir = path.join(this.paths.root, journal.backupDir);
|
|
157
|
+
if (!isPathInsideRoot(this.paths.stagingDir, nextDir) ||
|
|
158
|
+
!isPathInsideRoot(this.paths.root, backupDir)) {
|
|
159
|
+
throw new MediaValidationError("Commit recovery journal escaped data root.");
|
|
160
|
+
}
|
|
161
|
+
const activeValid = await this.#hasValidManifest(this.paths.activeDir);
|
|
162
|
+
const nextValid = await this.#hasValidManifest(nextDir);
|
|
163
|
+
const backupValid = await this.#hasValidManifest(backupDir);
|
|
164
|
+
if (!activeValid) {
|
|
165
|
+
await rmrf(this.paths.activeDir);
|
|
166
|
+
if (nextValid) {
|
|
167
|
+
await fs.rename(nextDir, this.paths.activeDir);
|
|
168
|
+
}
|
|
169
|
+
else if (backupValid) {
|
|
170
|
+
await fs.rename(backupDir, this.paths.activeDir);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
throw new MediaValidationError("Interrupted commit could not recover either active generation.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await rmrf(nextDir);
|
|
177
|
+
await rmrf(backupDir);
|
|
178
|
+
await fs.rm(this.#journalPath(), { force: true });
|
|
179
|
+
await fs.rm(this.#markerPath(), { force: true });
|
|
180
|
+
await fs.rm(this.#legacyMarkerPath(), { force: true }).catch(() => { });
|
|
181
|
+
}
|
|
182
|
+
async #createStagingDir() {
|
|
183
|
+
await fs.mkdir(this.paths.stagingDir, { recursive: true });
|
|
184
|
+
return fs.mkdtemp(path.join(this.paths.stagingDir, "tx-"));
|
|
185
|
+
}
|
|
186
|
+
async readActiveManifest() {
|
|
187
|
+
await this.init();
|
|
188
|
+
await this.#assertNotCommitting();
|
|
189
|
+
const manifestPath = path.join(this.paths.activeDir, MANIFEST_NAME);
|
|
190
|
+
const raw = await fs.readFile(manifestPath, "utf8");
|
|
191
|
+
// End-of-load marker check (race with writer).
|
|
192
|
+
await this.#assertNotCommitting();
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(raw);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new MediaValidationError("Active background manifest is not valid JSON.");
|
|
199
|
+
}
|
|
200
|
+
if (!isManifest(parsed)) {
|
|
201
|
+
throw new MediaValidationError("Active background manifest failed schema checks.");
|
|
202
|
+
}
|
|
203
|
+
if (parsed.background) {
|
|
204
|
+
assertSafeBasename(parsed.background.image, "background.image");
|
|
205
|
+
if (parsed.background.video) {
|
|
206
|
+
assertSafeBasename(parsed.background.video, "background.video");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return parsed;
|
|
210
|
+
}
|
|
211
|
+
async activeImagePath() {
|
|
212
|
+
const m = await this.readActiveManifest();
|
|
213
|
+
if (!m.background)
|
|
214
|
+
return null;
|
|
215
|
+
return path.join(this.paths.activeDir, m.background.image);
|
|
216
|
+
}
|
|
217
|
+
async activeVideoPath() {
|
|
218
|
+
const m = await this.readActiveManifest();
|
|
219
|
+
if (!m.background || m.background.type !== "video" || !m.background.video) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return path.join(this.paths.activeDir, m.background.video);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Serialize mutations. Prevents concurrent stage/commit interleaving
|
|
226
|
+
* (upstream tray recursion / dueling writers lesson).
|
|
227
|
+
*/
|
|
228
|
+
async #withWriteLock(fn) {
|
|
229
|
+
if (this.#writeContext.getStore())
|
|
230
|
+
return fn();
|
|
231
|
+
const guarded = async () => {
|
|
232
|
+
const lease = await acquireFileLock(path.join(this.paths.root, "store.lock"), { purpose: "beautiCode store mutation" });
|
|
233
|
+
try {
|
|
234
|
+
return await this.#writeContext.run(true, fn);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await lease.release();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const run = this.#writeChain.then(guarded, guarded);
|
|
241
|
+
this.#writeChain = run.then(() => undefined, () => undefined);
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Copy the active video to a renderer-only path outside `active`.
|
|
246
|
+
*
|
|
247
|
+
* Chromium keeps a Windows file handle for videos attached through
|
|
248
|
+
* DOM.setFileInputFiles. Pointing it at `active/background.mp4` prevents the
|
|
249
|
+
* next atomic active-directory rotation with EPERM. The detached copy keeps
|
|
250
|
+
* renderer lifetime independent from the disk transaction.
|
|
251
|
+
*/
|
|
252
|
+
async prepareRuntimeVideo(manifest) {
|
|
253
|
+
return this.#withWriteLock(async () => {
|
|
254
|
+
if (manifest.background?.type !== "video" ||
|
|
255
|
+
!manifest.background.video) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
await this.init();
|
|
259
|
+
const cached = this.#runtimeVideoCache.get(manifest.generation);
|
|
260
|
+
if (cached) {
|
|
261
|
+
try {
|
|
262
|
+
await validateVideoFile(cached);
|
|
263
|
+
return cached;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
this.#runtimeVideoCache.delete(manifest.generation);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const source = path.join(this.paths.activeDir, manifest.background.video);
|
|
270
|
+
const validated = await validateVideoFile(source);
|
|
271
|
+
const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
|
|
272
|
+
if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
|
|
273
|
+
throw new MediaValidationError("Runtime media path escaped runtime-media root.");
|
|
274
|
+
}
|
|
275
|
+
const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
|
|
276
|
+
if (!runtimeRootStat.isDirectory() ||
|
|
277
|
+
runtimeRootStat.isSymbolicLink()) {
|
|
278
|
+
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
279
|
+
}
|
|
280
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
281
|
+
const sessionStat = await fs.lstat(sessionDir);
|
|
282
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
283
|
+
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
284
|
+
}
|
|
285
|
+
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
286
|
+
fs.realpath(this.paths.runtimeMediaDir),
|
|
287
|
+
fs.realpath(sessionDir),
|
|
288
|
+
]);
|
|
289
|
+
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
290
|
+
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
291
|
+
}
|
|
292
|
+
const destination = path.join(sessionDir, `video-${manifest.generation}-${validated.identity.slice(0, 16)}.mp4`);
|
|
293
|
+
await copyFileAtomic(validated.filePath, destination);
|
|
294
|
+
const copied = await validateVideoFile(destination);
|
|
295
|
+
if (copied.identity !== validated.identity) {
|
|
296
|
+
await fs.rm(destination, { force: true }).catch(() => { });
|
|
297
|
+
throw new MediaValidationError("Runtime video copy failed its identity check.");
|
|
298
|
+
}
|
|
299
|
+
this.#runtimeVideoCache.set(manifest.generation, destination);
|
|
300
|
+
await this.#pruneRuntimeMediaUnlocked(destination);
|
|
301
|
+
return destination;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Best-effort cleanup. Locked stale files are retained and retried on the
|
|
306
|
+
* next apply/start instead of turning successful theme switching into an
|
|
307
|
+
* error.
|
|
308
|
+
*/
|
|
309
|
+
async pruneRuntimeMedia(keepPath = null) {
|
|
310
|
+
await this.#withWriteLock(async () => {
|
|
311
|
+
await this.init();
|
|
312
|
+
await this.#pruneRuntimeMediaUnlocked(keepPath);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
async #pruneRuntimeMediaUnlocked(keepPath) {
|
|
316
|
+
const runtimeRoot = path.resolve(this.paths.runtimeMediaDir);
|
|
317
|
+
const keep = keepPath && isPathInsideRoot(runtimeRoot, keepPath)
|
|
318
|
+
? path.resolve(keepPath)
|
|
319
|
+
: null;
|
|
320
|
+
let sessions;
|
|
321
|
+
try {
|
|
322
|
+
sessions = await fs.readdir(runtimeRoot);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
for (const name of sessions) {
|
|
328
|
+
if (!/^session-[A-Za-z0-9._-]+$/.test(name))
|
|
329
|
+
continue;
|
|
330
|
+
const sessionDir = path.join(runtimeRoot, name);
|
|
331
|
+
if (!isPathInsideRoot(runtimeRoot, sessionDir))
|
|
332
|
+
continue;
|
|
333
|
+
if (!keep || !isPathInsideRoot(sessionDir, keep)) {
|
|
334
|
+
await rmrf(sessionDir).catch(() => { });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
let files;
|
|
338
|
+
try {
|
|
339
|
+
files = await fs.readdir(sessionDir);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
const candidate = path.resolve(sessionDir, file);
|
|
346
|
+
if (candidate === keep || !isPathInsideRoot(sessionDir, candidate)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
await fs.rm(candidate, { recursive: true, force: true }).catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const [generation, cached] of this.#runtimeVideoCache) {
|
|
353
|
+
if (path.resolve(cached) !== keep) {
|
|
354
|
+
this.#runtimeVideoCache.delete(generation);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Hold the store lease across a multi-step apply/verify/rollback sequence.
|
|
360
|
+
* Nested store mutations in the same async context are re-entrant.
|
|
361
|
+
*/
|
|
362
|
+
async withExclusiveMutation(fn) {
|
|
363
|
+
return this.#withWriteLock(fn);
|
|
364
|
+
}
|
|
365
|
+
async snapshot() {
|
|
366
|
+
return this.#withWriteLock(async () => {
|
|
367
|
+
await this.init();
|
|
368
|
+
const manifest = await this.readActiveManifest();
|
|
369
|
+
const id = `snap-${Date.now()}-${process.pid}-${crypto.randomUUID()}`;
|
|
370
|
+
const dir = path.join(this.paths.snapshotsDir, id);
|
|
371
|
+
if (!isPathInsideRoot(this.paths.root, dir)) {
|
|
372
|
+
throw new MediaValidationError("Snapshot path escaped data root.");
|
|
373
|
+
}
|
|
374
|
+
await emptyDir(dir);
|
|
375
|
+
await this.#writeJsonAtomic(path.join(dir, MANIFEST_NAME), manifest);
|
|
376
|
+
if (manifest.background) {
|
|
377
|
+
const imgSrc = path.join(this.paths.activeDir, manifest.background.image);
|
|
378
|
+
const imgDst = path.join(dir, manifest.background.image);
|
|
379
|
+
await linkOrCopyFileAtomic(imgSrc, imgDst);
|
|
380
|
+
if (manifest.background.video) {
|
|
381
|
+
const vSrc = path.join(this.paths.activeDir, manifest.background.video);
|
|
382
|
+
const vDst = path.join(dir, manifest.background.video);
|
|
383
|
+
await linkOrCopyFileAtomic(vSrc, vDst);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { id, dir, manifest };
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async clearSnapshot(snapshot) {
|
|
390
|
+
await this.#assertSnapshotPath(snapshot);
|
|
391
|
+
await rmrf(snapshot.dir);
|
|
392
|
+
}
|
|
393
|
+
async #assertSnapshotPath(snapshot) {
|
|
394
|
+
const dir = path.resolve(snapshot.dir);
|
|
395
|
+
if (dir === path.resolve(this.paths.snapshotsDir) ||
|
|
396
|
+
!isPathInsideRoot(this.paths.snapshotsDir, dir) ||
|
|
397
|
+
!/^snap-[A-Za-z0-9._-]+$/.test(path.basename(dir))) {
|
|
398
|
+
throw new MediaValidationError("Snapshot must be a generated child of the snapshots directory.");
|
|
399
|
+
}
|
|
400
|
+
const st = await fs.lstat(dir);
|
|
401
|
+
if (!st.isDirectory() || st.isSymbolicLink()) {
|
|
402
|
+
throw new MediaValidationError("Snapshot directory cannot be a link.");
|
|
403
|
+
}
|
|
404
|
+
const [realRoot, realDir] = await Promise.all([
|
|
405
|
+
fs.realpath(this.paths.snapshotsDir),
|
|
406
|
+
fs.realpath(dir),
|
|
407
|
+
]);
|
|
408
|
+
if (realDir === realRoot ||
|
|
409
|
+
!isPathInsideRoot(realRoot, realDir)) {
|
|
410
|
+
throw new MediaValidationError("Snapshot real path escaped the snapshots directory.");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Import user media into a fresh staging tree and atomically promote it to
|
|
415
|
+
* active. Returns the new manifest.
|
|
416
|
+
*/
|
|
417
|
+
async commitImport(input) {
|
|
418
|
+
return this.#withWriteLock(async () => {
|
|
419
|
+
await this.init();
|
|
420
|
+
const previous = await this.readActiveManifest();
|
|
421
|
+
const generation = previous.generation + 1;
|
|
422
|
+
const stagingDir = await this.#createStagingDir();
|
|
423
|
+
try {
|
|
424
|
+
let background = null;
|
|
425
|
+
if (input.type === "image") {
|
|
426
|
+
const image = await validateImageFile(input.imagePath);
|
|
427
|
+
// Keep extension from source when it's an allowed one.
|
|
428
|
+
const basename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
|
|
429
|
+
assertSafeBasename(basename, "image");
|
|
430
|
+
await copyFileAtomic(image.filePath, path.join(stagingDir, basename));
|
|
431
|
+
background = { type: "image", image: basename };
|
|
432
|
+
const effects = normalizeBackgroundEffects(input.effects);
|
|
433
|
+
if (effects)
|
|
434
|
+
background.effects = effects;
|
|
435
|
+
}
|
|
436
|
+
else if (input.type === "video") {
|
|
437
|
+
const video = await validateVideoFile(input.videoPath);
|
|
438
|
+
assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
|
|
439
|
+
await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
|
|
440
|
+
// Poster: explicit path → reuse active image → synthetic 1x1 PNG.
|
|
441
|
+
let imageBasename;
|
|
442
|
+
if (input.imagePath) {
|
|
443
|
+
const image = await validateImageFile(input.imagePath);
|
|
444
|
+
imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
|
|
445
|
+
assertSafeBasename(imageBasename, "image");
|
|
446
|
+
await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
|
|
447
|
+
}
|
|
448
|
+
else if (previous.background?.image) {
|
|
449
|
+
const prevImage = path.join(this.paths.activeDir, previous.background.image);
|
|
450
|
+
const image = await validateImageFile(prevImage);
|
|
451
|
+
imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
|
|
452
|
+
assertSafeBasename(imageBasename, "image");
|
|
453
|
+
await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
imageBasename = "poster.png";
|
|
457
|
+
assertSafeBasename(imageBasename, "image");
|
|
458
|
+
await fs.writeFile(path.join(stagingDir, imageBasename), SYNTHETIC_POSTER_PNG);
|
|
459
|
+
}
|
|
460
|
+
background = {
|
|
461
|
+
type: "video",
|
|
462
|
+
image: imageBasename,
|
|
463
|
+
video: DEFAULT_VIDEO_BASENAME,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
background = null;
|
|
468
|
+
}
|
|
469
|
+
const manifest = {
|
|
470
|
+
schema: SCHEMA_ID,
|
|
471
|
+
generation,
|
|
472
|
+
background,
|
|
473
|
+
updatedAt: nowIso(),
|
|
474
|
+
};
|
|
475
|
+
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), manifest);
|
|
476
|
+
// Validate staged tree as a whole before promote.
|
|
477
|
+
await this.#validateTree(stagingDir, manifest);
|
|
478
|
+
await this.#promoteStagingToActive(stagingDir);
|
|
479
|
+
// Post-commit read must succeed.
|
|
480
|
+
return this.readActiveManifest();
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
await rmrf(stagingDir);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async restoreSnapshot(snapshot) {
|
|
488
|
+
return this.#withWriteLock(async () => {
|
|
489
|
+
await this.#assertSnapshotPath(snapshot);
|
|
490
|
+
await this.init();
|
|
491
|
+
const raw = await fs.readFile(path.join(snapshot.dir, MANIFEST_NAME), "utf8");
|
|
492
|
+
const manifest = JSON.parse(raw);
|
|
493
|
+
if (!isManifest(manifest)) {
|
|
494
|
+
throw new MediaValidationError("Snapshot manifest is invalid.");
|
|
495
|
+
}
|
|
496
|
+
// Bump generation so host payloads cannot be confused with the failed attempt.
|
|
497
|
+
const restored = {
|
|
498
|
+
...manifest,
|
|
499
|
+
generation: (await this.readActiveManifest()).generation + 1,
|
|
500
|
+
updatedAt: nowIso(),
|
|
501
|
+
};
|
|
502
|
+
const stagingDir = await this.#createStagingDir();
|
|
503
|
+
try {
|
|
504
|
+
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
|
|
505
|
+
if (restored.background) {
|
|
506
|
+
await copyFileAtomic(path.join(snapshot.dir, restored.background.image), path.join(stagingDir, restored.background.image));
|
|
507
|
+
if (restored.background.video) {
|
|
508
|
+
await copyFileAtomic(path.join(snapshot.dir, restored.background.video), path.join(stagingDir, restored.background.video));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
await this.#validateTree(stagingDir, restored);
|
|
512
|
+
await this.#promoteStagingToActive(stagingDir);
|
|
513
|
+
return this.readActiveManifest();
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await rmrf(stagingDir);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
async #validateTree(dir, manifest) {
|
|
521
|
+
if (!manifest.background)
|
|
522
|
+
return;
|
|
523
|
+
const imagePath = path.join(dir, manifest.background.image);
|
|
524
|
+
await validateImageFile(imagePath);
|
|
525
|
+
if (manifest.background.video) {
|
|
526
|
+
const videoPath = path.join(dir, manifest.background.video);
|
|
527
|
+
await validateVideoFile(videoPath);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async #promoteStagingToActive(stagingDir) {
|
|
531
|
+
if (stagingDir === this.paths.stagingDir ||
|
|
532
|
+
!isPathInsideRoot(this.paths.stagingDir, stagingDir)) {
|
|
533
|
+
throw new MediaValidationError("Staging transaction escaped staging root.");
|
|
534
|
+
}
|
|
535
|
+
const token = `${Date.now()}-${process.pid}-${crypto.randomUUID()}`;
|
|
536
|
+
const backupDir = path.join(this.paths.root, `active-backup-${token}`);
|
|
537
|
+
const journal = {
|
|
538
|
+
version: 1,
|
|
539
|
+
phase: "prepared",
|
|
540
|
+
nextDir: path.basename(stagingDir),
|
|
541
|
+
backupDir: path.basename(backupDir),
|
|
542
|
+
};
|
|
543
|
+
await fs.writeFile(this.#markerPath(), token, {
|
|
544
|
+
encoding: "utf8",
|
|
545
|
+
flag: "wx",
|
|
546
|
+
mode: 0o600,
|
|
547
|
+
});
|
|
548
|
+
await this.#writeJsonAtomic(this.#journalPath(), journal);
|
|
549
|
+
try {
|
|
550
|
+
await fs.rename(this.paths.activeDir, backupDir);
|
|
551
|
+
journal.phase = "old-moved";
|
|
552
|
+
await this.#writeJsonAtomic(this.#journalPath(), journal);
|
|
553
|
+
await fs.rename(stagingDir, this.paths.activeDir);
|
|
554
|
+
journal.phase = "new-active";
|
|
555
|
+
await this.#writeJsonAtomic(this.#journalPath(), journal);
|
|
556
|
+
if (!(await this.#hasValidManifest(this.paths.activeDir))) {
|
|
557
|
+
throw new MediaValidationError("Promoted active manifest is invalid.");
|
|
558
|
+
}
|
|
559
|
+
await rmrf(backupDir);
|
|
560
|
+
await fs.rm(this.#journalPath(), { force: true });
|
|
561
|
+
await fs.rm(this.#markerPath(), { force: true });
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
await this.#recoverInterruptedCommit().catch(() => { });
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Snapshot the current active image/video into saved/<id>/ so the user can
|
|
570
|
+
* restore it later ("保存当前主题"). No-op-safe: refuses when background is clear.
|
|
571
|
+
* Optional videoPositionSec is stored on video themes for resume-on-restore.
|
|
572
|
+
*/
|
|
573
|
+
async saveCurrentTheme(name, opts = {}) {
|
|
574
|
+
return this.#withWriteLock(async () => {
|
|
575
|
+
await this.init();
|
|
576
|
+
const trimmed = String(name ?? "").trim();
|
|
577
|
+
if (!trimmed || trimmed.length > 80) {
|
|
578
|
+
throw new MediaValidationError("Theme name must be 1–80 characters.");
|
|
579
|
+
}
|
|
580
|
+
if (/[<>:"/\|?*]/.test(trimmed) || Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
|
|
581
|
+
throw new MediaValidationError("Theme name contains illegal characters.");
|
|
582
|
+
}
|
|
583
|
+
const manifest = await this.readActiveManifest();
|
|
584
|
+
if (!manifest.background) {
|
|
585
|
+
throw new MediaValidationError("No active background to save.");
|
|
586
|
+
}
|
|
587
|
+
const usage = await this.#savedThemeUsage();
|
|
588
|
+
if (usage.count >= this.maxSavedThemes) {
|
|
589
|
+
throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
|
|
590
|
+
}
|
|
591
|
+
let incomingBytes = (await fs.stat(path.join(this.paths.activeDir, manifest.background.image))).size;
|
|
592
|
+
if (manifest.background.video) {
|
|
593
|
+
incomingBytes += (await fs.stat(path.join(this.paths.activeDir, manifest.background.video))).size;
|
|
594
|
+
}
|
|
595
|
+
if (usage.bytes + incomingBytes > this.maxSavedBytes) {
|
|
596
|
+
throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
|
|
597
|
+
}
|
|
598
|
+
const id = slugThemeId(trimmed);
|
|
599
|
+
const dir = path.join(this.paths.savedDir, id);
|
|
600
|
+
if (!isPathInsideRoot(this.paths.savedDir, dir)) {
|
|
601
|
+
throw new MediaValidationError("Saved theme path escaped saved root.");
|
|
602
|
+
}
|
|
603
|
+
const stagingDir = await this.#createStagingDir();
|
|
604
|
+
try {
|
|
605
|
+
const imageSrc = path.join(this.paths.activeDir, manifest.background.image);
|
|
606
|
+
await copyFileAtomic(imageSrc, path.join(stagingDir, manifest.background.image));
|
|
607
|
+
if (manifest.background.video) {
|
|
608
|
+
await copyFileAtomic(path.join(this.paths.activeDir, manifest.background.video), path.join(stagingDir, manifest.background.video));
|
|
609
|
+
}
|
|
610
|
+
// Persist media only — generation is assigned on restore.
|
|
611
|
+
const savedManifest = {
|
|
612
|
+
schema: SCHEMA_ID,
|
|
613
|
+
generation: 0,
|
|
614
|
+
background: { ...manifest.background },
|
|
615
|
+
updatedAt: nowIso(),
|
|
616
|
+
};
|
|
617
|
+
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), savedManifest);
|
|
618
|
+
const meta = {
|
|
619
|
+
id,
|
|
620
|
+
name: trimmed,
|
|
621
|
+
type: manifest.background.type,
|
|
622
|
+
savedAt: nowIso(),
|
|
623
|
+
};
|
|
624
|
+
if (manifest.background.type === "video") {
|
|
625
|
+
const pos = normalizeVideoPositionSec(opts.videoPositionSec);
|
|
626
|
+
if (pos != null) {
|
|
627
|
+
meta.videoPositionSec = pos;
|
|
628
|
+
meta.videoPositionUpdatedAt = nowIso();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
await this.#writeJsonAtomic(path.join(stagingDir, SAVED_META_NAME), meta);
|
|
632
|
+
await this.#validateTree(stagingDir, savedManifest);
|
|
633
|
+
// Same-volume directory rename makes a saved theme appear all at once.
|
|
634
|
+
await fs.rename(stagingDir, dir);
|
|
635
|
+
const info = {
|
|
636
|
+
id,
|
|
637
|
+
name: trimmed,
|
|
638
|
+
type: manifest.background.type,
|
|
639
|
+
path: dir,
|
|
640
|
+
savedAt: meta.savedAt,
|
|
641
|
+
};
|
|
642
|
+
if (typeof meta.videoPositionSec === "number") {
|
|
643
|
+
info.videoPositionSec = meta.videoPositionSec;
|
|
644
|
+
}
|
|
645
|
+
return info;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
await rmrf(stagingDir);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
async listSavedThemes() {
|
|
653
|
+
await this.init();
|
|
654
|
+
let entries = [];
|
|
655
|
+
try {
|
|
656
|
+
entries = await fs.readdir(this.paths.savedDir);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
const out = [];
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
const dir = path.join(this.paths.savedDir, entry);
|
|
664
|
+
if (!isPathInsideRoot(this.paths.savedDir, dir))
|
|
665
|
+
continue;
|
|
666
|
+
try {
|
|
667
|
+
await this.#assertSafeSavedThemeDir(dir);
|
|
668
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
669
|
+
const meta = JSON.parse(metaRaw);
|
|
670
|
+
if (!meta || typeof meta.name !== "string") {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
// Ensure media still present.
|
|
674
|
+
const mRaw = await fs.readFile(path.join(dir, MANIFEST_NAME), "utf8");
|
|
675
|
+
const m = JSON.parse(mRaw);
|
|
676
|
+
if (!isManifest(m) || !m.background)
|
|
677
|
+
continue;
|
|
678
|
+
// Directory basename is the authoritative id (filesystem truth).
|
|
679
|
+
// Older builds stored non-ASCII meta.id that useSavedTheme rejected.
|
|
680
|
+
const pos = normalizeVideoPositionSec(meta.videoPositionSec);
|
|
681
|
+
out.push({
|
|
682
|
+
id: entry,
|
|
683
|
+
name: meta.name,
|
|
684
|
+
type: m.background.type,
|
|
685
|
+
path: dir,
|
|
686
|
+
savedAt: typeof meta.savedAt === "string" ? meta.savedAt : "",
|
|
687
|
+
...(pos != null && m.background.type === "video"
|
|
688
|
+
? { videoPositionSec: pos }
|
|
689
|
+
: {}),
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* skip broken entries */
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const bundledIds = new Set(this.bundledThemes.map((spec) => spec.id));
|
|
697
|
+
const disk = out.filter((theme) => !bundledIds.has(theme.id));
|
|
698
|
+
const bundled = await this.#bundledThemeInfos();
|
|
699
|
+
const listed = [...bundled, ...disk];
|
|
700
|
+
listed.sort((a, b) => {
|
|
701
|
+
const bundledRank = Number(Boolean(b.bundled)) - Number(Boolean(a.bundled));
|
|
702
|
+
if (bundledRank !== 0)
|
|
703
|
+
return bundledRank;
|
|
704
|
+
return a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0;
|
|
705
|
+
});
|
|
706
|
+
return listed;
|
|
707
|
+
}
|
|
708
|
+
async deleteSavedTheme(themeId) {
|
|
709
|
+
return this.#withWriteLock(async () => {
|
|
710
|
+
await this.init();
|
|
711
|
+
const id = String(themeId ?? "").trim();
|
|
712
|
+
if (!isSafeThemeId(id)) {
|
|
713
|
+
throw new MediaValidationError("Invalid saved theme id.");
|
|
714
|
+
}
|
|
715
|
+
if (this.#isBundledThemeId(id)) {
|
|
716
|
+
throw new MediaValidationError("Built-in theme cannot be deleted.");
|
|
717
|
+
}
|
|
718
|
+
const dir = await this.#resolveSavedThemeDir(id);
|
|
719
|
+
if (!dir)
|
|
720
|
+
return false;
|
|
721
|
+
await this.#assertSafeSavedThemeDir(dir);
|
|
722
|
+
await rmrf(dir);
|
|
723
|
+
return true;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Read saved theme resume position (seconds). Image themes / missing → null.
|
|
728
|
+
* Invalid values → null (caller seeks to 0).
|
|
729
|
+
*/
|
|
730
|
+
async getSavedThemeVideoPosition(themeId) {
|
|
731
|
+
await this.init();
|
|
732
|
+
const id = String(themeId ?? "").trim();
|
|
733
|
+
if (!isSafeThemeId(id))
|
|
734
|
+
return null;
|
|
735
|
+
const dir = await this.#resolveSavedThemeDir(id);
|
|
736
|
+
if (!dir)
|
|
737
|
+
return null;
|
|
738
|
+
try {
|
|
739
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
740
|
+
const meta = JSON.parse(metaRaw);
|
|
741
|
+
if (!meta || meta.type !== "video")
|
|
742
|
+
return null;
|
|
743
|
+
return normalizeVideoPositionSec(meta.videoPositionSec);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Continuously update a video theme's resume position on disk.
|
|
751
|
+
* Bound to that theme only. No-op / refuse for image themes or missing ids.
|
|
752
|
+
* Skips rewrite when the rounded position is unchanged (reduces disk churn).
|
|
753
|
+
*/
|
|
754
|
+
async updateSavedThemeVideoPosition(themeId, positionSec) {
|
|
755
|
+
return this.#withWriteLock(async () => {
|
|
756
|
+
await this.init();
|
|
757
|
+
const id = String(themeId ?? "").trim();
|
|
758
|
+
if (!isSafeThemeId(id)) {
|
|
759
|
+
return { ok: false, positionSec: null, error: "Invalid saved theme id." };
|
|
760
|
+
}
|
|
761
|
+
const dir = await this.#resolveSavedThemeDir(id);
|
|
762
|
+
if (!dir) {
|
|
763
|
+
return { ok: false, positionSec: null, error: "Saved theme not found." };
|
|
764
|
+
}
|
|
765
|
+
const pos = normalizeVideoPositionSec(positionSec);
|
|
766
|
+
if (pos == null) {
|
|
767
|
+
// Explicit invalid → write 0 so corrupt live values recover to start.
|
|
768
|
+
// Callers that want "leave alone" should filter first.
|
|
769
|
+
return { ok: false, positionSec: null, error: "Invalid video position." };
|
|
770
|
+
}
|
|
771
|
+
let meta;
|
|
772
|
+
try {
|
|
773
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
774
|
+
meta = JSON.parse(metaRaw);
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return { ok: false, positionSec: null, error: "Theme meta unreadable." };
|
|
778
|
+
}
|
|
779
|
+
if (!meta || typeof meta.name !== "string") {
|
|
780
|
+
return { ok: false, positionSec: null, error: "Theme meta invalid." };
|
|
781
|
+
}
|
|
782
|
+
if (meta.type !== "video") {
|
|
783
|
+
return { ok: false, positionSec: null, error: "Not a video theme." };
|
|
784
|
+
}
|
|
785
|
+
const prev = normalizeVideoPositionSec(meta.videoPositionSec);
|
|
786
|
+
// Skip identical position (same ms) to avoid thrashing the disk every poll.
|
|
787
|
+
if (prev != null && prev === pos) {
|
|
788
|
+
return { ok: true, positionSec: pos };
|
|
789
|
+
}
|
|
790
|
+
const next = {
|
|
791
|
+
...meta,
|
|
792
|
+
id: typeof meta.id === "string" ? meta.id : id,
|
|
793
|
+
type: "video",
|
|
794
|
+
videoPositionSec: pos,
|
|
795
|
+
videoPositionUpdatedAt: nowIso(),
|
|
796
|
+
};
|
|
797
|
+
await this.#writeJsonAtomic(path.join(dir, SAVED_META_NAME), next);
|
|
798
|
+
return { ok: true, positionSec: pos };
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Resolve a saved theme to ordinary import paths without mutating active.
|
|
803
|
+
* The caller can feed this into ApplyTransaction so host verify and rollback
|
|
804
|
+
* cover saved-theme switches too.
|
|
805
|
+
*/
|
|
806
|
+
async loadSavedTheme(themeId) {
|
|
807
|
+
await this.init();
|
|
808
|
+
const id = String(themeId ?? "").trim();
|
|
809
|
+
if (!isSafeThemeId(id)) {
|
|
810
|
+
throw new MediaValidationError("Invalid saved theme id.");
|
|
811
|
+
}
|
|
812
|
+
const bundled = this.#bundledThemeSpec(id);
|
|
813
|
+
if (bundled) {
|
|
814
|
+
await validateImageFile(bundled.imagePath);
|
|
815
|
+
const input = {
|
|
816
|
+
type: "image",
|
|
817
|
+
imagePath: bundled.imagePath,
|
|
818
|
+
};
|
|
819
|
+
const effects = normalizeBackgroundEffects(bundled.effects);
|
|
820
|
+
if (effects)
|
|
821
|
+
input.effects = effects;
|
|
822
|
+
return {
|
|
823
|
+
input,
|
|
824
|
+
videoPositionSec: null,
|
|
825
|
+
themeId: id,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const dir = await this.#resolveSavedThemeDir(id);
|
|
829
|
+
if (!dir) {
|
|
830
|
+
throw new MediaValidationError("Saved theme not found.");
|
|
831
|
+
}
|
|
832
|
+
await this.#assertSafeSavedThemeDir(dir);
|
|
833
|
+
const raw = await fs.readFile(path.join(dir, MANIFEST_NAME), "utf8");
|
|
834
|
+
const parsed = JSON.parse(raw);
|
|
835
|
+
if (!isManifest(parsed) || !parsed.background) {
|
|
836
|
+
throw new MediaValidationError("Saved theme manifest is invalid.");
|
|
837
|
+
}
|
|
838
|
+
await this.#validateTree(dir, parsed);
|
|
839
|
+
const imagePath = path.join(dir, parsed.background.image);
|
|
840
|
+
if (parsed.background.type === "image") {
|
|
841
|
+
const input = {
|
|
842
|
+
type: "image",
|
|
843
|
+
imagePath,
|
|
844
|
+
};
|
|
845
|
+
const effects = normalizeBackgroundEffects(parsed.background.effects);
|
|
846
|
+
if (effects)
|
|
847
|
+
input.effects = effects;
|
|
848
|
+
return {
|
|
849
|
+
input,
|
|
850
|
+
videoPositionSec: null,
|
|
851
|
+
themeId: id,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (!parsed.background.video) {
|
|
855
|
+
throw new MediaValidationError("Saved video theme has no video file.");
|
|
856
|
+
}
|
|
857
|
+
let videoPositionSec = null;
|
|
858
|
+
try {
|
|
859
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
860
|
+
const meta = JSON.parse(metaRaw);
|
|
861
|
+
videoPositionSec = normalizeVideoPositionSec(meta?.videoPositionSec);
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
videoPositionSec = null;
|
|
865
|
+
}
|
|
866
|
+
const input = {
|
|
867
|
+
type: "video",
|
|
868
|
+
imagePath,
|
|
869
|
+
videoPath: path.join(dir, parsed.background.video),
|
|
870
|
+
};
|
|
871
|
+
if (videoPositionSec != null && videoPositionSec > 0) {
|
|
872
|
+
input.startAt = videoPositionSec;
|
|
873
|
+
}
|
|
874
|
+
return { input, videoPositionSec, themeId: id };
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Promote a previously saved theme into active (bumps generation).
|
|
878
|
+
* Returns the new active manifest plus the resume position from theme meta
|
|
879
|
+
* (video only; invalid/missing → null → start at 0).
|
|
880
|
+
*/
|
|
881
|
+
async useSavedTheme(themeId) {
|
|
882
|
+
return this.#withWriteLock(async () => {
|
|
883
|
+
await this.init();
|
|
884
|
+
const id = String(themeId ?? "").trim();
|
|
885
|
+
if (!isSafeThemeId(id)) {
|
|
886
|
+
throw new MediaValidationError("Invalid saved theme id.");
|
|
887
|
+
}
|
|
888
|
+
const bundled = this.#bundledThemeSpec(id);
|
|
889
|
+
if (bundled) {
|
|
890
|
+
const input = {
|
|
891
|
+
type: "image",
|
|
892
|
+
imagePath: bundled.imagePath,
|
|
893
|
+
};
|
|
894
|
+
const effects = normalizeBackgroundEffects(bundled.effects);
|
|
895
|
+
if (effects)
|
|
896
|
+
input.effects = effects;
|
|
897
|
+
const manifest = await this.commitImport(input);
|
|
898
|
+
return { manifest, videoPositionSec: null };
|
|
899
|
+
}
|
|
900
|
+
const dir = await this.#resolveSavedThemeDir(id);
|
|
901
|
+
if (!dir) {
|
|
902
|
+
throw new MediaValidationError("Saved theme not found.");
|
|
903
|
+
}
|
|
904
|
+
const raw = await fs.readFile(path.join(dir, MANIFEST_NAME), "utf8");
|
|
905
|
+
const parsed = JSON.parse(raw);
|
|
906
|
+
if (!isManifest(parsed) || !parsed.background) {
|
|
907
|
+
throw new MediaValidationError("Saved theme manifest is invalid.");
|
|
908
|
+
}
|
|
909
|
+
await this.#validateTree(dir, parsed);
|
|
910
|
+
let videoPositionSec = null;
|
|
911
|
+
if (parsed.background.type === "video") {
|
|
912
|
+
try {
|
|
913
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
914
|
+
const meta = JSON.parse(metaRaw);
|
|
915
|
+
videoPositionSec = normalizeVideoPositionSec(meta?.videoPositionSec);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
videoPositionSec = null;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const previous = await this.readActiveManifest();
|
|
922
|
+
const restored = {
|
|
923
|
+
schema: SCHEMA_ID,
|
|
924
|
+
generation: previous.generation + 1,
|
|
925
|
+
background: { ...parsed.background },
|
|
926
|
+
updatedAt: nowIso(),
|
|
927
|
+
};
|
|
928
|
+
const stagingDir = await this.#createStagingDir();
|
|
929
|
+
try {
|
|
930
|
+
await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
|
|
931
|
+
await copyFileAtomic(path.join(dir, restored.background.image), path.join(stagingDir, restored.background.image));
|
|
932
|
+
if (restored.background.video) {
|
|
933
|
+
await copyFileAtomic(path.join(dir, restored.background.video), path.join(stagingDir, restored.background.video));
|
|
934
|
+
}
|
|
935
|
+
await this.#validateTree(stagingDir, restored);
|
|
936
|
+
await this.#promoteStagingToActive(stagingDir);
|
|
937
|
+
const manifest = await this.readActiveManifest();
|
|
938
|
+
return { manifest, videoPositionSec };
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
await rmrf(stagingDir);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Resolve a saved theme directory by folder name (preferred) or legacy meta.id.
|
|
947
|
+
*/
|
|
948
|
+
async #resolveSavedThemeDir(themeId) {
|
|
949
|
+
const direct = path.join(this.paths.savedDir, themeId);
|
|
950
|
+
if (isPathInsideRoot(this.paths.savedDir, direct)) {
|
|
951
|
+
try {
|
|
952
|
+
await this.#assertSafeSavedThemeDir(direct);
|
|
953
|
+
await fs.access(path.join(direct, MANIFEST_NAME));
|
|
954
|
+
return direct;
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
/* fall through to meta.id scan */
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// Legacy: meta.id may differ from directory name (encoding / old slugger).
|
|
961
|
+
let entries = [];
|
|
962
|
+
try {
|
|
963
|
+
entries = await fs.readdir(this.paths.savedDir);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
for (const entry of entries) {
|
|
969
|
+
const dir = path.join(this.paths.savedDir, entry);
|
|
970
|
+
if (!isPathInsideRoot(this.paths.savedDir, dir))
|
|
971
|
+
continue;
|
|
972
|
+
try {
|
|
973
|
+
await this.#assertSafeSavedThemeDir(dir);
|
|
974
|
+
const metaRaw = await fs.readFile(path.join(dir, SAVED_META_NAME), "utf8");
|
|
975
|
+
const meta = JSON.parse(metaRaw);
|
|
976
|
+
if (meta && typeof meta.id === "string" && meta.id === themeId) {
|
|
977
|
+
await fs.access(path.join(dir, MANIFEST_NAME));
|
|
978
|
+
return dir;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
/* skip */
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
async #assertSafeSavedThemeDir(dir) {
|
|
988
|
+
if (!isPathInsideRoot(this.paths.savedDir, dir)) {
|
|
989
|
+
throw new MediaValidationError("Saved theme path escaped saved root.");
|
|
990
|
+
}
|
|
991
|
+
const st = await fs.lstat(dir);
|
|
992
|
+
if (!st.isDirectory() || st.isSymbolicLink()) {
|
|
993
|
+
throw new MediaValidationError("Saved theme directory cannot be a link.");
|
|
994
|
+
}
|
|
995
|
+
const [realRoot, realDir] = await Promise.all([
|
|
996
|
+
fs.realpath(this.paths.savedDir),
|
|
997
|
+
fs.realpath(dir),
|
|
998
|
+
]);
|
|
999
|
+
if (!isPathInsideRoot(realRoot, realDir)) {
|
|
1000
|
+
throw new MediaValidationError("Saved theme real path escaped saved root.");
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
#bundledThemeSpec(themeId) {
|
|
1004
|
+
return this.bundledThemes.find((spec) => spec.id === themeId) ?? null;
|
|
1005
|
+
}
|
|
1006
|
+
#isBundledThemeId(themeId) {
|
|
1007
|
+
return (themeId === BUNDLED_GALLERY_THEME_ID ||
|
|
1008
|
+
this.#bundledThemeSpec(themeId) != null);
|
|
1009
|
+
}
|
|
1010
|
+
async #bundledThemeInfos() {
|
|
1011
|
+
const out = [];
|
|
1012
|
+
for (const spec of this.bundledThemes) {
|
|
1013
|
+
try {
|
|
1014
|
+
const st = await fs.stat(spec.imagePath);
|
|
1015
|
+
if (!st.isFile() || st.size <= 0)
|
|
1016
|
+
continue;
|
|
1017
|
+
out.push({
|
|
1018
|
+
id: spec.id,
|
|
1019
|
+
name: spec.name,
|
|
1020
|
+
type: "image",
|
|
1021
|
+
path: spec.imagePath,
|
|
1022
|
+
savedAt: "",
|
|
1023
|
+
bundled: true,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
/* shipped asset missing in this checkout / install */
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
async #savedThemeUsage() {
|
|
1033
|
+
const themes = await this.listSavedThemes();
|
|
1034
|
+
let count = 0;
|
|
1035
|
+
let bytes = 0;
|
|
1036
|
+
for (const theme of themes) {
|
|
1037
|
+
if (theme.bundled || this.#isBundledThemeId(theme.id))
|
|
1038
|
+
continue;
|
|
1039
|
+
count += 1;
|
|
1040
|
+
try {
|
|
1041
|
+
await this.#assertSafeSavedThemeDir(theme.path);
|
|
1042
|
+
const raw = await fs.readFile(path.join(theme.path, MANIFEST_NAME), "utf8");
|
|
1043
|
+
const manifest = JSON.parse(raw);
|
|
1044
|
+
if (!isManifest(manifest) || !manifest.background)
|
|
1045
|
+
continue;
|
|
1046
|
+
bytes += (await fs.stat(path.join(theme.path, manifest.background.image))).size;
|
|
1047
|
+
if (manifest.background.video) {
|
|
1048
|
+
bytes += (await fs.stat(path.join(theme.path, manifest.background.video))).size;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
/* broken entries do not count as valid themes */
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return { count, bytes };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Normalize a playback position for theme meta. Invalid → null (caller treats as 0). */
|
|
1059
|
+
export function normalizeVideoPositionSec(value) {
|
|
1060
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
1061
|
+
if (!Number.isFinite(n) || n < 0)
|
|
1062
|
+
return null;
|
|
1063
|
+
// Cap absurd values so a corrupt meta cannot force huge seeks.
|
|
1064
|
+
if (n > 24 * 60 * 60)
|
|
1065
|
+
return null;
|
|
1066
|
+
// Millisecond-ish precision is enough for resume.
|
|
1067
|
+
return Math.round(n * 1000) / 1000;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Stable ASCII folder id. Display name stays Unicode in theme.json;
|
|
1071
|
+
* Chinese (and other non-ASCII) must NOT appear in the id — useSavedTheme
|
|
1072
|
+
* and Windows path tooling previously rejected those with "Invalid theme id".
|
|
1073
|
+
*/
|
|
1074
|
+
function slugThemeId(_name) {
|
|
1075
|
+
const stamp = Date.now().toString(36);
|
|
1076
|
+
const rand = crypto.randomUUID().replaceAll("-", "");
|
|
1077
|
+
return `theme-${stamp}-${rand}`.slice(0, 80);
|
|
1078
|
+
}
|
|
1079
|
+
function normalizeBundledThemes(value) {
|
|
1080
|
+
if (!Array.isArray(value))
|
|
1081
|
+
return [];
|
|
1082
|
+
const out = [];
|
|
1083
|
+
const seen = new Set();
|
|
1084
|
+
for (const item of value) {
|
|
1085
|
+
if (!item || typeof item !== "object")
|
|
1086
|
+
continue;
|
|
1087
|
+
const raw = item;
|
|
1088
|
+
const id = String(raw.id ?? "").trim();
|
|
1089
|
+
const name = String(raw.name ?? "").trim();
|
|
1090
|
+
const imagePath = String(raw.imagePath ?? "").trim();
|
|
1091
|
+
if (!isSafeThemeId(id) || seen.has(id) || !name || name.length > 80 || !imagePath) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
seen.add(id);
|
|
1095
|
+
const spec = { id, name, imagePath };
|
|
1096
|
+
const effects = normalizeBackgroundEffects(raw.effects);
|
|
1097
|
+
if (effects)
|
|
1098
|
+
spec.effects = effects;
|
|
1099
|
+
out.push(spec);
|
|
1100
|
+
}
|
|
1101
|
+
return out;
|
|
1102
|
+
}
|
|
1103
|
+
/** Path-segment safe theme id (no traversal). Allows legacy non-ASCII dirs. */
|
|
1104
|
+
function isSafeThemeId(id) {
|
|
1105
|
+
if (!id || id.length > 120)
|
|
1106
|
+
return false;
|
|
1107
|
+
if (id === "." || id === ".." || id.includes("\0"))
|
|
1108
|
+
return false;
|
|
1109
|
+
if (id.includes("/") || id.includes("\\"))
|
|
1110
|
+
return false;
|
|
1111
|
+
// Windows reserved device names
|
|
1112
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(id))
|
|
1113
|
+
return false;
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
//# sourceMappingURL=background-store.js.map
|