beauticode-dsh 1.0.7 → 1.0.13

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.
@@ -4,11 +4,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import crypto from "node:crypto";
5
5
  import { COMMIT_MARKER_NAME, COMMIT_MARKER_STALE_MS, DEFAULT_VIDEO_BASENAME, MANIFEST_NAME, SAVED_META_NAME, SCHEMA_ID, } from "./constants.js";
6
6
  import { assertSafeBasename, MediaValidationError, validateImageFile, validateVideoFile, } from "./media-validation.js";
7
- import { copyFileAtomic, emptyDir, ensureDataLayout, isPathInsideRoot, linkOrCopyFileAtomic, resolveDataPaths, rmrf, } from "./paths.js";
7
+ import { copyFileAtomic, emptyDir, ensureDataLayout, isPathInsideRoot, resolveDataPaths, rmrf, } from "./paths.js";
8
8
  import { normalizeBackgroundEffects } from "./types.js";
9
+ import { isLocalBackgroundSource, managedPrimaryFile, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
9
10
  import { BUNDLED_GALLERY_THEME_ID, } from "./bundled-gallery.js";
10
11
  import { acquireFileLock } from "./file-lock.js";
11
- import { normalizeSavedThemeSource, } from "./skin-pack.js";
12
12
  /** 1×1 transparent PNG — poster when video apply has no prior image. */
13
13
  const SYNTHETIC_POSTER_PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082", "hex");
14
14
  const COMMIT_JOURNAL_NAME = ".beauticode-commit.json";
@@ -41,9 +41,33 @@ function isManifest(value) {
41
41
  const b = v.background;
42
42
  if (b.type !== "image" && b.type !== "video")
43
43
  return false;
44
- if (typeof b.image !== "string")
44
+ if (b.image != null && typeof b.image !== "string")
45
45
  return false;
46
- if (b.type === "video" && typeof b.video !== "string")
46
+ if (b.video != null && typeof b.video !== "string")
47
+ return false;
48
+ if (b.source != null) {
49
+ if (!b.source || typeof b.source !== "object")
50
+ return false;
51
+ const source = b.source;
52
+ if (source.kind === "local") {
53
+ if (typeof source.path !== "string" || !path.isAbsolute(source.path))
54
+ return false;
55
+ }
56
+ else if (source.kind === "managed") {
57
+ if (typeof source.file !== "string")
58
+ return false;
59
+ }
60
+ else {
61
+ return false;
62
+ }
63
+ }
64
+ // Legacy v1 manifests have no source field. New local image manifests do
65
+ // not need an active copy; video manifests still retain a managed poster.
66
+ if (!b.source && b.type === "image" && typeof b.image !== "string")
67
+ return false;
68
+ if (!b.source && b.type === "video" && typeof b.video !== "string")
69
+ return false;
70
+ if (b.type === "video" && typeof b.image !== "string")
47
71
  return false;
48
72
  if (b.effects != null && !normalizeBackgroundEffects(b.effects))
49
73
  return false;
@@ -56,26 +80,27 @@ export class BackgroundStore {
56
80
  maxSavedBytes;
57
81
  bundledThemes;
58
82
  #runtimeSessionName = `session-${process.pid}-${crypto.randomUUID()}`;
59
- #runtimeMediaCache = new Map();
83
+ #runtimeVideoCache = new Map();
60
84
  #writeChain = Promise.resolve();
61
85
  #writeContext = new AsyncLocalStorage();
62
86
  constructor(opts = {}) {
63
87
  this.paths = resolveDataPaths(opts.root);
64
88
  this.commitMarkerStaleMs = opts.commitMarkerStaleMs ?? COMMIT_MARKER_STALE_MS;
65
89
  this.maxSavedThemes = opts.maxSavedThemes ?? 20;
66
- this.maxSavedBytes = opts.maxSavedBytes ?? 8 * 1024 * 1024 * 1024;
90
+ this.maxSavedBytes = opts.maxSavedBytes ?? 2 * 1024 * 1024 * 1024;
67
91
  this.bundledThemes = normalizeBundledThemes(opts.bundledThemes);
68
92
  }
69
93
  async init() {
70
94
  await ensureDataLayout(this.paths);
71
95
  const markerFresh = await this.#isCommitMarkerFresh();
72
- if (!markerFresh || this.#writeContext.getStore()) {
73
- await this.#recoverInterruptedCommit();
74
- await fs.mkdir(this.paths.activeDir, { recursive: true });
75
- }
76
- else {
96
+ // A fresh marker always belongs to a live transaction. Readers and
97
+ // re-entrant writer calls must leave its journal/staging tree untouched;
98
+ // recovery is only safe after the marker becomes stale or disappears.
99
+ if (markerFresh) {
77
100
  return;
78
101
  }
102
+ await this.#recoverInterruptedCommit();
103
+ await fs.mkdir(this.paths.activeDir, { recursive: true });
79
104
  const manifestPath = path.join(this.paths.activeDir, MANIFEST_NAME);
80
105
  try {
81
106
  await fs.access(manifestPath);
@@ -202,10 +227,15 @@ export class BackgroundStore {
202
227
  throw new MediaValidationError("Active background manifest failed schema checks.");
203
228
  }
204
229
  if (parsed.background) {
205
- assertSafeBasename(parsed.background.image, "background.image");
230
+ if (parsed.background.image) {
231
+ assertSafeBasename(parsed.background.image, "background.image");
232
+ }
206
233
  if (parsed.background.video) {
207
234
  assertSafeBasename(parsed.background.video, "background.video");
208
235
  }
236
+ if (parsed.background.source?.kind === "managed") {
237
+ assertSafeBasename(parsed.background.source.file, "background.source.file");
238
+ }
209
239
  }
210
240
  return parsed;
211
241
  }
@@ -213,14 +243,14 @@ export class BackgroundStore {
213
243
  const m = await this.readActiveManifest();
214
244
  if (!m.background)
215
245
  return null;
216
- return path.join(this.paths.activeDir, m.background.image);
246
+ return resolveBackgroundImagePath(this.paths.activeDir, m.background);
217
247
  }
218
248
  async activeVideoPath() {
219
249
  const m = await this.readActiveManifest();
220
- if (!m.background || m.background.type !== "video" || !m.background.video) {
250
+ if (!m.background || m.background.type !== "video") {
221
251
  return null;
222
252
  }
223
- return path.join(this.paths.activeDir, m.background.video);
253
+ return resolveBackgroundVideoPath(this.paths.activeDir, m.background);
224
254
  }
225
255
  /**
226
256
  * Serialize mutations. Prevents concurrent stage/commit interleaving
@@ -243,99 +273,68 @@ export class BackgroundStore {
243
273
  return run;
244
274
  }
245
275
  /**
246
- * Copy the active still to a renderer-only path outside `active`.
247
- *
248
- * Serving `active/poster.png` keeps that directory open on Windows, so the
249
- * next atomic active-directory rotation fails with EPERM. The detached copy
250
- * matches the video runtime path.
251
- */
252
- async prepareRuntimeImage(manifest) {
253
- if (!manifest.background?.image)
254
- return null;
255
- return this.#prepareRuntimeMedia({
256
- generation: manifest.generation,
257
- kind: "image",
258
- sourceName: manifest.background.image,
259
- ext: path.extname(manifest.background.image) || ".png",
260
- validate: (filePath) => validateImageFile(filePath),
261
- });
262
- }
263
- /**
264
- * Copy the active video to a renderer-only path outside `active`.
265
- *
266
- * Chromium keeps a Windows file handle for videos attached through
267
- * DOM.setFileInputFiles. Pointing it at `active/background.mp4` prevents the
268
- * next atomic active-directory rotation with EPERM. The detached copy keeps
269
- * renderer lifetime independent from the disk transaction.
276
+ * Resolve the renderer video path. Managed videos get a detached runtime
277
+ * copy because Chromium can keep an active-directory handle open. Local
278
+ * references already live outside active, so returning the source path avoids
279
+ * both the active copy and the runtime copy.
270
280
  */
271
281
  async prepareRuntimeVideo(manifest) {
272
- if (manifest.background?.type !== "video" ||
273
- !manifest.background.video) {
274
- return null;
275
- }
276
- return this.#prepareRuntimeMedia({
277
- generation: manifest.generation,
278
- kind: "video",
279
- sourceName: manifest.background.video,
280
- ext: ".mp4",
281
- validate: (filePath) => validateVideoFile(filePath),
282
- });
283
- }
284
- async #prepareRuntimeMedia(opts) {
285
282
  return this.#withWriteLock(async () => {
283
+ if (manifest.background?.type !== "video") {
284
+ return null;
285
+ }
286
286
  await this.init();
287
- const cacheKey = `${opts.kind}:${opts.generation}`;
288
- const cached = this.#runtimeMediaCache.get(cacheKey);
287
+ const source = resolveBackgroundVideoPath(this.paths.activeDir, manifest.background);
288
+ if (!source)
289
+ return null;
290
+ if (isLocalBackgroundSource(manifest.background)) {
291
+ await validateVideoFile(source, { mode: "fast" });
292
+ return source;
293
+ }
294
+ const cached = this.#runtimeVideoCache.get(manifest.generation);
289
295
  if (cached) {
290
296
  try {
291
- await opts.validate(cached);
297
+ await validateVideoFile(cached);
292
298
  return cached;
293
299
  }
294
300
  catch {
295
- this.#runtimeMediaCache.delete(cacheKey);
301
+ this.#runtimeVideoCache.delete(manifest.generation);
296
302
  }
297
303
  }
298
- const source = path.join(this.paths.activeDir, opts.sourceName);
299
- const validated = await opts.validate(source);
300
- const sessionDir = await this.#ensureRuntimeSessionDir();
301
- const safeExt = opts.ext.toLowerCase().startsWith(".")
302
- ? opts.ext.toLowerCase()
303
- : `.${opts.ext}`;
304
- const destination = path.join(sessionDir, `${opts.kind}-${opts.generation}-${validated.identity.slice(0, 16)}${safeExt}`);
304
+ const validated = await validateVideoFile(source);
305
+ const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
306
+ if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
307
+ throw new MediaValidationError("Runtime media path escaped runtime-media root.");
308
+ }
309
+ const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
310
+ if (!runtimeRootStat.isDirectory() ||
311
+ runtimeRootStat.isSymbolicLink()) {
312
+ throw new MediaValidationError("Runtime media root must be a real directory.");
313
+ }
314
+ await fs.mkdir(sessionDir, { recursive: true });
315
+ const sessionStat = await fs.lstat(sessionDir);
316
+ if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
317
+ throw new MediaValidationError("Runtime media session must be a real directory.");
318
+ }
319
+ const [realRuntimeRoot, realSessionDir] = await Promise.all([
320
+ fs.realpath(this.paths.runtimeMediaDir),
321
+ fs.realpath(sessionDir),
322
+ ]);
323
+ if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
324
+ throw new MediaValidationError("Runtime media session escaped runtime-media root.");
325
+ }
326
+ const destination = path.join(sessionDir, `video-${manifest.generation}-${validated.identity.slice(0, 16)}.mp4`);
305
327
  await copyFileAtomic(validated.filePath, destination);
306
- const copied = await opts.validate(destination);
328
+ const copied = await validateVideoFile(destination);
307
329
  if (copied.identity !== validated.identity) {
308
330
  await fs.rm(destination, { force: true }).catch(() => { });
309
- throw new MediaValidationError(`Runtime ${opts.kind} copy failed its identity check.`);
331
+ throw new MediaValidationError("Runtime video copy failed its identity check.");
310
332
  }
311
- this.#runtimeMediaCache.set(cacheKey, destination);
312
- await this.#pruneRuntimeMediaUnlocked([...this.#runtimeMediaCache.values()]);
333
+ this.#runtimeVideoCache.set(manifest.generation, destination);
334
+ await this.#pruneRuntimeMediaUnlocked(destination);
313
335
  return destination;
314
336
  });
315
337
  }
316
- async #ensureRuntimeSessionDir() {
317
- const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
318
- if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
319
- throw new MediaValidationError("Runtime media path escaped runtime-media root.");
320
- }
321
- const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
322
- if (!runtimeRootStat.isDirectory() || runtimeRootStat.isSymbolicLink()) {
323
- throw new MediaValidationError("Runtime media root must be a real directory.");
324
- }
325
- await fs.mkdir(sessionDir, { recursive: true });
326
- const sessionStat = await fs.lstat(sessionDir);
327
- if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
328
- throw new MediaValidationError("Runtime media session must be a real directory.");
329
- }
330
- const [realRuntimeRoot, realSessionDir] = await Promise.all([
331
- fs.realpath(this.paths.runtimeMediaDir),
332
- fs.realpath(sessionDir),
333
- ]);
334
- if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
335
- throw new MediaValidationError("Runtime media session escaped runtime-media root.");
336
- }
337
- return sessionDir;
338
- }
339
338
  /**
340
339
  * Best-effort cleanup. Locked stale files are retained and retried on the
341
340
  * next apply/start instead of turning successful theme switching into an
@@ -344,15 +343,14 @@ export class BackgroundStore {
344
343
  async pruneRuntimeMedia(keepPath = null) {
345
344
  await this.#withWriteLock(async () => {
346
345
  await this.init();
347
- const keep = (Array.isArray(keepPath) ? keepPath : [keepPath]).filter((value) => typeof value === "string" && value.length > 0);
348
- await this.#pruneRuntimeMediaUnlocked(keep);
346
+ await this.#pruneRuntimeMediaUnlocked(keepPath);
349
347
  });
350
348
  }
351
- async #pruneRuntimeMediaUnlocked(keepPaths) {
349
+ async #pruneRuntimeMediaUnlocked(keepPath) {
352
350
  const runtimeRoot = path.resolve(this.paths.runtimeMediaDir);
353
- const keep = new Set(keepPaths
354
- .filter((value) => isPathInsideRoot(runtimeRoot, value))
355
- .map((value) => path.resolve(value)));
351
+ const keep = keepPath && isPathInsideRoot(runtimeRoot, keepPath)
352
+ ? path.resolve(keepPath)
353
+ : null;
356
354
  let sessions;
357
355
  try {
358
356
  sessions = await fs.readdir(runtimeRoot);
@@ -366,8 +364,7 @@ export class BackgroundStore {
366
364
  const sessionDir = path.join(runtimeRoot, name);
367
365
  if (!isPathInsideRoot(runtimeRoot, sessionDir))
368
366
  continue;
369
- const keepInSession = [...keep].some((file) => isPathInsideRoot(sessionDir, file));
370
- if (!keepInSession) {
367
+ if (!keep || !isPathInsideRoot(sessionDir, keep)) {
371
368
  await rmrf(sessionDir).catch(() => { });
372
369
  continue;
373
370
  }
@@ -380,15 +377,15 @@ export class BackgroundStore {
380
377
  }
381
378
  for (const file of files) {
382
379
  const candidate = path.resolve(sessionDir, file);
383
- if (keep.has(candidate) || !isPathInsideRoot(sessionDir, candidate)) {
380
+ if (candidate === keep || !isPathInsideRoot(sessionDir, candidate)) {
384
381
  continue;
385
382
  }
386
383
  await fs.rm(candidate, { recursive: true, force: true }).catch(() => { });
387
384
  }
388
385
  }
389
- for (const [key, cached] of this.#runtimeMediaCache) {
390
- if (!keep.has(path.resolve(cached))) {
391
- this.#runtimeMediaCache.delete(key);
386
+ for (const [generation, cached] of this.#runtimeVideoCache) {
387
+ if (path.resolve(cached) !== keep) {
388
+ this.#runtimeVideoCache.delete(generation);
392
389
  }
393
390
  }
394
391
  }
@@ -411,14 +408,7 @@ export class BackgroundStore {
411
408
  await emptyDir(dir);
412
409
  await this.#writeJsonAtomic(path.join(dir, MANIFEST_NAME), manifest);
413
410
  if (manifest.background) {
414
- const imgSrc = path.join(this.paths.activeDir, manifest.background.image);
415
- const imgDst = path.join(dir, manifest.background.image);
416
- await linkOrCopyFileAtomic(imgSrc, imgDst);
417
- if (manifest.background.video) {
418
- const vSrc = path.join(this.paths.activeDir, manifest.background.video);
419
- const vDst = path.join(dir, manifest.background.video);
420
- await linkOrCopyFileAtomic(vSrc, vDst);
421
- }
411
+ await this.#copyManagedBackgroundFiles(this.paths.activeDir, dir, manifest.background);
422
412
  }
423
413
  return { id, dir, manifest };
424
414
  });
@@ -460,20 +450,45 @@ export class BackgroundStore {
460
450
  try {
461
451
  let background = null;
462
452
  if (input.type === "image") {
463
- const image = await validateImageFile(input.imagePath);
464
- // Keep extension from source when it's an allowed one.
465
- const basename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
466
- assertSafeBasename(basename, "image");
467
- await copyFileAtomic(image.filePath, path.join(stagingDir, basename));
468
- background = { type: "image", image: basename };
469
- const effects = normalizeBackgroundEffects(input.effects);
470
- if (effects)
471
- background.effects = effects;
453
+ const mode = input.source ?? "managed";
454
+ if (mode !== "local" && mode !== "managed") {
455
+ throw new MediaValidationError(`Unsupported image import mode: ${mode}`);
456
+ }
457
+ const image = await validateImageFile(input.imagePath, {
458
+ mode: mode === "local" ? "fast" : "full",
459
+ });
460
+ if (mode === "local") {
461
+ background = {
462
+ type: "image",
463
+ source: { kind: "local", path: image.filePath },
464
+ };
465
+ const effects = normalizeBackgroundEffects(input.effects);
466
+ if (effects)
467
+ background.effects = effects;
468
+ }
469
+ else {
470
+ // Keep extension from source when it's an allowed one.
471
+ const basename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
472
+ assertSafeBasename(basename, "image");
473
+ await copyFileAtomic(image.filePath, path.join(stagingDir, basename));
474
+ background = { type: "image", image: basename };
475
+ const effects = normalizeBackgroundEffects(input.effects);
476
+ if (effects)
477
+ background.effects = effects;
478
+ }
472
479
  }
473
480
  else if (input.type === "video") {
474
- const video = await validateVideoFile(input.videoPath);
475
- assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
476
- await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
481
+ const mode = input.source ?? "managed";
482
+ if (mode !== "local" && mode !== "managed") {
483
+ throw new MediaValidationError(`Unsupported video import mode: ${mode}`);
484
+ }
485
+ const video = await validateVideoFile(input.videoPath, {
486
+ mode: mode === "local" ? "fast" : "full",
487
+ });
488
+ if (mode === "managed") {
489
+ assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
490
+ await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
491
+ }
477
492
  // Poster: explicit path → reuse active image → synthetic 1x1 PNG.
478
493
  let imageBasename;
479
494
  if (input.imagePath) {
@@ -482,8 +497,11 @@ export class BackgroundStore {
482
497
  assertSafeBasename(imageBasename, "image");
483
498
  await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
484
499
  }
485
- else if (previous.background?.image) {
486
- const prevImage = path.join(this.paths.activeDir, previous.background.image);
500
+ else if (previous.background) {
501
+ const prevImage = resolveBackgroundImagePath(this.paths.activeDir, previous.background);
502
+ if (!prevImage) {
503
+ throw new MediaValidationError("Previous background has no poster image.");
504
+ }
487
505
  const image = await validateImageFile(prevImage);
488
506
  imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
489
507
  assertSafeBasename(imageBasename, "image");
@@ -497,7 +515,9 @@ export class BackgroundStore {
497
515
  background = {
498
516
  type: "video",
499
517
  image: imageBasename,
500
- video: DEFAULT_VIDEO_BASENAME,
518
+ ...(mode === "local"
519
+ ? { source: { kind: "local", path: video.filePath } }
520
+ : { video: DEFAULT_VIDEO_BASENAME }),
501
521
  };
502
522
  }
503
523
  else {
@@ -540,10 +560,7 @@ export class BackgroundStore {
540
560
  try {
541
561
  await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
542
562
  if (restored.background) {
543
- await copyFileAtomic(path.join(snapshot.dir, restored.background.image), path.join(stagingDir, restored.background.image));
544
- if (restored.background.video) {
545
- await copyFileAtomic(path.join(snapshot.dir, restored.background.video), path.join(stagingDir, restored.background.video));
546
- }
563
+ await this.#copyManagedBackgroundFiles(snapshot.dir, stagingDir, restored.background);
547
564
  }
548
565
  await this.#validateTree(stagingDir, restored);
549
566
  await this.#promoteStagingToActive(stagingDir);
@@ -557,11 +574,29 @@ export class BackgroundStore {
557
574
  async #validateTree(dir, manifest) {
558
575
  if (!manifest.background)
559
576
  return;
560
- const imagePath = path.join(dir, manifest.background.image);
561
- await validateImageFile(imagePath);
562
- if (manifest.background.video) {
563
- const videoPath = path.join(dir, manifest.background.video);
564
- await validateVideoFile(videoPath);
577
+ const imagePath = resolveBackgroundImagePath(dir, manifest.background);
578
+ if (!imagePath) {
579
+ throw new MediaValidationError("Background has no image source.");
580
+ }
581
+ await validateImageFile(imagePath, {
582
+ mode: manifest.background.type === "image" && isLocalBackgroundSource(manifest.background)
583
+ ? "fast"
584
+ : "full",
585
+ });
586
+ const videoPath = resolveBackgroundVideoPath(dir, manifest.background);
587
+ if (videoPath) {
588
+ await validateVideoFile(videoPath, {
589
+ mode: isLocalBackgroundSource(manifest.background) ? "fast" : "full",
590
+ });
591
+ }
592
+ }
593
+ async #copyManagedBackgroundFiles(sourceDir, destinationDir, background) {
594
+ if (background.image) {
595
+ await copyFileAtomic(path.join(sourceDir, background.image), path.join(destinationDir, background.image));
596
+ }
597
+ const primaryFile = managedPrimaryFile(background);
598
+ if (primaryFile && primaryFile !== background.image) {
599
+ await copyFileAtomic(path.join(sourceDir, primaryFile), path.join(destinationDir, primaryFile));
565
600
  }
566
601
  }
567
602
  async #promoteStagingToActive(stagingDir) {
@@ -614,7 +649,7 @@ export class BackgroundStore {
614
649
  if (!trimmed || trimmed.length > 80) {
615
650
  throw new MediaValidationError("Theme name must be 1–80 characters.");
616
651
  }
617
- if (/[<>:"/\|?*]/.test(trimmed) || Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
652
+ if (/[<>:"/\\|?*]/.test(trimmed) || Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
618
653
  throw new MediaValidationError("Theme name contains illegal characters.");
619
654
  }
620
655
  const manifest = await this.readActiveManifest();
@@ -625,9 +660,19 @@ export class BackgroundStore {
625
660
  if (usage.count >= this.maxSavedThemes) {
626
661
  throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
627
662
  }
628
- let incomingBytes = (await fs.stat(path.join(this.paths.activeDir, manifest.background.image))).size;
629
- if (manifest.background.video) {
630
- incomingBytes += (await fs.stat(path.join(this.paths.activeDir, manifest.background.video))).size;
663
+ const imagePath = resolveBackgroundImagePath(this.paths.activeDir, manifest.background);
664
+ const primaryPath = manifest.background.type === "video"
665
+ ? resolveBackgroundVideoPath(this.paths.activeDir, manifest.background)
666
+ : imagePath;
667
+ if (!imagePath || !primaryPath) {
668
+ throw new MediaValidationError("Active background has no resolvable media source.");
669
+ }
670
+ let incomingBytes = 0;
671
+ if (manifest.background.type === "video") {
672
+ incomingBytes += (await fs.stat(imagePath)).size;
673
+ }
674
+ if (!isLocalBackgroundSource(manifest.background)) {
675
+ incomingBytes += (await fs.stat(primaryPath)).size;
631
676
  }
632
677
  if (usage.bytes + incomingBytes > this.maxSavedBytes) {
633
678
  throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
@@ -639,11 +684,7 @@ export class BackgroundStore {
639
684
  }
640
685
  const stagingDir = await this.#createStagingDir();
641
686
  try {
642
- const imageSrc = path.join(this.paths.activeDir, manifest.background.image);
643
- await copyFileAtomic(imageSrc, path.join(stagingDir, manifest.background.image));
644
- if (manifest.background.video) {
645
- await copyFileAtomic(path.join(this.paths.activeDir, manifest.background.video), path.join(stagingDir, manifest.background.video));
646
- }
687
+ await this.#copyManagedBackgroundFiles(this.paths.activeDir, stagingDir, manifest.background);
647
688
  // Persist media only — generation is assigned on restore.
648
689
  const savedManifest = {
649
690
  schema: SCHEMA_ID,
@@ -675,6 +716,7 @@ export class BackgroundStore {
675
716
  type: manifest.background.type,
676
717
  path: dir,
677
718
  savedAt: meta.savedAt,
719
+ sourceMode: isLocalBackgroundSource(manifest.background) ? "local" : "managed",
678
720
  };
679
721
  if (typeof meta.videoPositionSec === "number") {
680
722
  info.videoPositionSec = meta.videoPositionSec;
@@ -686,138 +728,6 @@ export class BackgroundStore {
686
728
  }
687
729
  });
688
730
  }
689
- /**
690
- * Copy validated local media into saved/ without changing the active
691
- * background. Used by the skin-center installer.
692
- */
693
- async importSavedThemeFromMedia(input) {
694
- return this.#withWriteLock(async () => {
695
- await this.init();
696
- const trimmed = String(input?.name ?? "").trim();
697
- if (!trimmed || trimmed.length > 80) {
698
- throw new MediaValidationError("Theme name must be 1–80 characters.");
699
- }
700
- if (/[<>:"/\\|?*]/.test(trimmed) ||
701
- Array.from(trimmed).some((ch) => ch.charCodeAt(0) < 32)) {
702
- throw new MediaValidationError("Theme name contains illegal characters.");
703
- }
704
- const source = normalizeSavedThemeSource(input.source);
705
- if (source) {
706
- const existing = await this.findSavedThemeBySource(source);
707
- if (existing)
708
- return existing;
709
- }
710
- const imagePath = String(input.imagePath ?? "").trim();
711
- const videoPath = String(input.videoPath ?? "").trim();
712
- if (!imagePath && !videoPath) {
713
- throw new MediaValidationError("Import requires an image or video file.");
714
- }
715
- const video = videoPath ? await validateVideoFile(videoPath) : null;
716
- let image = null;
717
- if (imagePath) {
718
- image = await validateImageFile(imagePath);
719
- }
720
- else if (video) {
721
- try {
722
- const previous = await this.readActiveManifest();
723
- if (previous.background?.image) {
724
- image = await validateImageFile(path.join(this.paths.activeDir, previous.background.image));
725
- }
726
- }
727
- catch {
728
- image = null;
729
- }
730
- }
731
- const usage = await this.#savedThemeUsage();
732
- if (usage.count >= this.maxSavedThemes) {
733
- throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
734
- }
735
- const incomingBytes = (image?.size ?? SYNTHETIC_POSTER_PNG.length) + (video?.size ?? 0);
736
- if (usage.bytes + incomingBytes > this.maxSavedBytes) {
737
- throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
738
- }
739
- const id = slugThemeId(trimmed);
740
- const dir = path.join(this.paths.savedDir, id);
741
- if (!isPathInsideRoot(this.paths.savedDir, dir)) {
742
- throw new MediaValidationError("Saved theme path escaped saved root.");
743
- }
744
- const stagingDir = await this.#createStagingDir();
745
- try {
746
- let imageBasename;
747
- if (image) {
748
- imageBasename = `poster${image.extension === ".jpeg" ? ".jpg" : image.extension}`;
749
- assertSafeBasename(imageBasename, "image");
750
- await copyFileAtomic(image.filePath, path.join(stagingDir, imageBasename));
751
- }
752
- else {
753
- imageBasename = "poster.png";
754
- assertSafeBasename(imageBasename, "image");
755
- await fs.writeFile(path.join(stagingDir, imageBasename), SYNTHETIC_POSTER_PNG);
756
- }
757
- const background = video
758
- ? {
759
- type: "video",
760
- image: imageBasename,
761
- video: DEFAULT_VIDEO_BASENAME,
762
- }
763
- : { type: "image", image: imageBasename };
764
- if (!video) {
765
- const effects = normalizeBackgroundEffects(input.effects);
766
- if (effects)
767
- background.effects = effects;
768
- }
769
- if (video) {
770
- assertSafeBasename(DEFAULT_VIDEO_BASENAME, "video");
771
- await copyFileAtomic(video.filePath, path.join(stagingDir, DEFAULT_VIDEO_BASENAME));
772
- }
773
- const savedManifest = {
774
- schema: SCHEMA_ID,
775
- generation: 0,
776
- background,
777
- updatedAt: nowIso(),
778
- };
779
- await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), savedManifest);
780
- const meta = {
781
- id,
782
- name: trimmed,
783
- type: background.type,
784
- savedAt: nowIso(),
785
- };
786
- if (source)
787
- meta.source = source;
788
- if (video) {
789
- const pos = normalizeVideoPositionSec(input.videoPositionSec);
790
- if (pos != null) {
791
- meta.videoPositionSec = pos;
792
- meta.videoPositionUpdatedAt = nowIso();
793
- }
794
- }
795
- await this.#writeJsonAtomic(path.join(stagingDir, SAVED_META_NAME), meta);
796
- await this.#validateTree(stagingDir, savedManifest);
797
- await fs.rename(stagingDir, dir);
798
- return {
799
- id,
800
- name: trimmed,
801
- type: background.type,
802
- path: dir,
803
- savedAt: meta.savedAt,
804
- ...(source ? { source } : {}),
805
- };
806
- }
807
- finally {
808
- await rmrf(stagingDir);
809
- }
810
- });
811
- }
812
- async findSavedThemeBySource(source) {
813
- const wanted = normalizeSavedThemeSource(source);
814
- if (!wanted)
815
- return null;
816
- const listed = await this.listSavedThemes();
817
- return (listed.find((theme) => theme.source?.kind === wanted.kind &&
818
- theme.source.skinId === wanted.skinId &&
819
- theme.source.centerUrl === wanted.centerUrl) ?? null);
820
- }
821
731
  async listSavedThemes() {
822
732
  await this.init();
823
733
  let entries = [];
@@ -847,17 +757,16 @@ export class BackgroundStore {
847
757
  // Directory basename is the authoritative id (filesystem truth).
848
758
  // Older builds stored non-ASCII meta.id that useSavedTheme rejected.
849
759
  const pos = normalizeVideoPositionSec(meta.videoPositionSec);
850
- const source = normalizeSavedThemeSource(meta.source);
851
760
  out.push({
852
761
  id: entry,
853
762
  name: meta.name,
854
763
  type: m.background.type,
855
764
  path: dir,
856
765
  savedAt: typeof meta.savedAt === "string" ? meta.savedAt : "",
766
+ sourceMode: isLocalBackgroundSource(m.background) ? "local" : "managed",
857
767
  ...(pos != null && m.background.type === "video"
858
768
  ? { videoPositionSec: pos }
859
769
  : {}),
860
- ...(source ? { source } : {}),
861
770
  });
862
771
  }
863
772
  catch {
@@ -1007,11 +916,18 @@ export class BackgroundStore {
1007
916
  throw new MediaValidationError("Saved theme manifest is invalid.");
1008
917
  }
1009
918
  await this.#validateTree(dir, parsed);
1010
- const imagePath = path.join(dir, parsed.background.image);
919
+ const imagePath = resolveBackgroundImagePath(dir, parsed.background);
920
+ if (!imagePath) {
921
+ throw new MediaValidationError("Saved theme has no image source.");
922
+ }
923
+ const sourceMode = isLocalBackgroundSource(parsed.background)
924
+ ? "local"
925
+ : "managed";
1011
926
  if (parsed.background.type === "image") {
1012
927
  const input = {
1013
928
  type: "image",
1014
929
  imagePath,
930
+ source: sourceMode,
1015
931
  };
1016
932
  const effects = normalizeBackgroundEffects(parsed.background.effects);
1017
933
  if (effects)
@@ -1022,7 +938,8 @@ export class BackgroundStore {
1022
938
  themeId: id,
1023
939
  };
1024
940
  }
1025
- if (!parsed.background.video) {
941
+ const videoPath = resolveBackgroundVideoPath(dir, parsed.background);
942
+ if (!videoPath) {
1026
943
  throw new MediaValidationError("Saved video theme has no video file.");
1027
944
  }
1028
945
  let videoPositionSec = null;
@@ -1037,7 +954,8 @@ export class BackgroundStore {
1037
954
  const input = {
1038
955
  type: "video",
1039
956
  imagePath,
1040
- videoPath: path.join(dir, parsed.background.video),
957
+ videoPath,
958
+ source: sourceMode,
1041
959
  };
1042
960
  if (videoPositionSec != null && videoPositionSec > 0) {
1043
961
  input.startAt = videoPositionSec;
@@ -1099,10 +1017,7 @@ export class BackgroundStore {
1099
1017
  const stagingDir = await this.#createStagingDir();
1100
1018
  try {
1101
1019
  await this.#writeJsonAtomic(path.join(stagingDir, MANIFEST_NAME), restored);
1102
- await copyFileAtomic(path.join(dir, restored.background.image), path.join(stagingDir, restored.background.image));
1103
- if (restored.background.video) {
1104
- await copyFileAtomic(path.join(dir, restored.background.video), path.join(stagingDir, restored.background.video));
1105
- }
1020
+ await this.#copyManagedBackgroundFiles(dir, stagingDir, restored.background);
1106
1021
  await this.#validateTree(stagingDir, restored);
1107
1022
  await this.#promoteStagingToActive(stagingDir);
1108
1023
  const manifest = await this.readActiveManifest();
@@ -1191,6 +1106,7 @@ export class BackgroundStore {
1191
1106
  type: "image",
1192
1107
  path: spec.imagePath,
1193
1108
  savedAt: "",
1109
+ sourceMode: "managed",
1194
1110
  bundled: true,
1195
1111
  });
1196
1112
  }
@@ -1214,9 +1130,17 @@ export class BackgroundStore {
1214
1130
  const manifest = JSON.parse(raw);
1215
1131
  if (!isManifest(manifest) || !manifest.background)
1216
1132
  continue;
1217
- bytes += (await fs.stat(path.join(theme.path, manifest.background.image))).size;
1218
- if (manifest.background.video) {
1219
- bytes += (await fs.stat(path.join(theme.path, manifest.background.video))).size;
1133
+ const imagePath = resolveBackgroundImagePath(theme.path, manifest.background);
1134
+ const primaryPath = manifest.background.type === "video"
1135
+ ? resolveBackgroundVideoPath(theme.path, manifest.background)
1136
+ : imagePath;
1137
+ if (!imagePath || !primaryPath)
1138
+ continue;
1139
+ if (manifest.background.type === "video") {
1140
+ bytes += (await fs.stat(imagePath)).size;
1141
+ }
1142
+ if (!isLocalBackgroundSource(manifest.background)) {
1143
+ bytes += (await fs.stat(primaryPath)).size;
1220
1144
  }
1221
1145
  }
1222
1146
  catch {
@@ -1226,70 +1150,6 @@ export class BackgroundStore {
1226
1150
  return { count, bytes };
1227
1151
  }
1228
1152
  }
1229
- /**
1230
- * Display name for an imported file. Id stays a random ASCII folder; this is
1231
- * only the label shown in 已保存主题.
1232
- */
1233
- export function themeNameFromFilePath(filePath, fallback = "主题") {
1234
- const base = path.basename(String(filePath ?? "").replaceAll("\\", "/"));
1235
- const ext = path.extname(base);
1236
- let name = (ext ? base.slice(0, -ext.length) : base).trim();
1237
- name = name
1238
- .replace(/[<>:"/\\|?*]/g, " ")
1239
- .replace(/[\u0000-\u001f]/g, "")
1240
- .replace(/\s+/g, " ")
1241
- .trim();
1242
- if (!name)
1243
- name = fallback;
1244
- if (name.length > 80)
1245
- name = name.slice(0, 80).trim();
1246
- if (!name)
1247
- name = fallback;
1248
- return name;
1249
- }
1250
- export function importedThemeInputFromApply(input, name) {
1251
- if (input.type === "image") {
1252
- const next = {
1253
- name: String(name ?? "").trim() || themeNameFromFilePath(input.imagePath, "图片"),
1254
- imagePath: input.imagePath,
1255
- };
1256
- if (input.effects)
1257
- next.effects = input.effects;
1258
- return next;
1259
- }
1260
- const next = {
1261
- name: String(name ?? "").trim() || themeNameFromFilePath(input.videoPath, "视频"),
1262
- videoPath: input.videoPath,
1263
- };
1264
- if (input.imagePath)
1265
- next.imagePath = input.imagePath;
1266
- const pos = normalizeVideoPositionSec(input.startAt);
1267
- if (pos != null)
1268
- next.videoPositionSec = pos;
1269
- return next;
1270
- }
1271
- export async function persistAndApplyImportedMedia(session, input, opts = {}) {
1272
- if (input.type !== "image" && input.type !== "video") {
1273
- return {
1274
- ok: false,
1275
- error: "Imported media must be an image or video.",
1276
- rolledBack: false,
1277
- };
1278
- }
1279
- let theme;
1280
- try {
1281
- theme = await session.importSavedTheme(importedThemeInputFromApply(input, opts.name));
1282
- }
1283
- catch (error) {
1284
- return {
1285
- ok: false,
1286
- error: error instanceof Error ? error.message : String(error),
1287
- rolledBack: false,
1288
- };
1289
- }
1290
- const result = await session.useSavedTheme(theme.id);
1291
- return { ...result, theme };
1292
- }
1293
1153
  /** Normalize a playback position for theme meta. Invalid → null (caller treats as 0). */
1294
1154
  export function normalizeVideoPositionSec(value) {
1295
1155
  const n = typeof value === "number" ? value : Number(value);