beauticode-dsh 1.0.3 → 1.0.5
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 +1 -1
- package/agent.mjs +138 -23
- package/cli.js +151 -15
- package/console.js +37 -9
- package/index.mjs +5 -0
- package/package.json +1 -1
- package/ui-host.mjs +32 -3
- package/vendor/adapter-dsh/session.js +13 -10
- package/vendor/core/apply-transaction.js +19 -7
- package/vendor/core/background-store.js +186 -54
- package/vendor/core/error-message.js +2 -0
- package/vendor/core/media-server.js +23 -9
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。
|
|
4
4
|
|
|
5
|
-
装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4
|
|
5
|
+
装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换或删除已保存主题,以及打开「皮肤中心」安装已审核的社区皮肤。导入的图片和视频会自动进入已保存主题。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。
|
|
6
6
|
|
|
7
7
|
插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。
|
|
8
8
|
|
package/agent.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
callDshControl,
|
|
3
4
|
formatStatusText,
|
|
@@ -5,7 +6,7 @@ import {
|
|
|
5
6
|
matchSavedTheme,
|
|
6
7
|
stripPathQuotes,
|
|
7
8
|
} from "./control-client.mjs";
|
|
8
|
-
import { resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
|
|
9
|
+
import { loadAdapter, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
|
|
9
10
|
import { ATMOSPHERE_PRESETS, effectsForPreset, presetImagePath } from "./presets.mjs";
|
|
10
11
|
|
|
11
12
|
const APPLY_TIMEOUT_MS = 180_000;
|
|
@@ -32,6 +33,55 @@ function fail(error) {
|
|
|
32
33
|
throw error instanceof Error ? error : new Error(String(error));
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
export function themeNameFromFilePath(filePath, fallback = "主题") {
|
|
37
|
+
const base = path.basename(String(filePath ?? "").replaceAll("\\", "/"));
|
|
38
|
+
const ext = path.extname(base);
|
|
39
|
+
let name = (ext ? base.slice(0, -ext.length) : base).trim();
|
|
40
|
+
name = name
|
|
41
|
+
.replace(/[<>:"/\\|?*]/g, " ")
|
|
42
|
+
.replace(/[\u0000-\u001f]/g, "")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
if (!name) name = fallback;
|
|
46
|
+
if (name.length > 80) name = name.slice(0, 80).trim();
|
|
47
|
+
if (!name) name = fallback;
|
|
48
|
+
return name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function chineseError(error) {
|
|
52
|
+
const raw = error instanceof Error ? error.message : String(error ?? "");
|
|
53
|
+
try {
|
|
54
|
+
const adapter = await loadAdapter();
|
|
55
|
+
if (typeof adapter.toChineseErrorMessage === "function") {
|
|
56
|
+
return adapter.toChineseErrorMessage(error);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* keep raw */
|
|
60
|
+
}
|
|
61
|
+
return raw || "操作失败。";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function unwrapApplyResult(result, fallbackMode, message) {
|
|
65
|
+
if (!result || result.ok === false) {
|
|
66
|
+
fail(await chineseError(result?.error || "操作失败。"));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
generation: result.generation ?? null,
|
|
71
|
+
mode: result.mode ?? fallbackMode,
|
|
72
|
+
message,
|
|
73
|
+
...(result.theme
|
|
74
|
+
? {
|
|
75
|
+
theme: {
|
|
76
|
+
id: result.theme.id,
|
|
77
|
+
name: result.theme.name,
|
|
78
|
+
type: result.theme.type ?? null,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
35
85
|
function commandResultFromError(error) {
|
|
36
86
|
return {
|
|
37
87
|
kind: "error",
|
|
@@ -57,6 +107,15 @@ function unwrapApply(result, fallbackMode, message) {
|
|
|
57
107
|
generation: result.generation ?? null,
|
|
58
108
|
mode: result.mode ?? fallbackMode,
|
|
59
109
|
message,
|
|
110
|
+
...(result.theme
|
|
111
|
+
? {
|
|
112
|
+
theme: {
|
|
113
|
+
id: result.theme.id,
|
|
114
|
+
name: result.theme.name,
|
|
115
|
+
type: result.theme.type ?? null,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
60
119
|
};
|
|
61
120
|
}
|
|
62
121
|
|
|
@@ -69,6 +128,7 @@ function presentStatus(status) {
|
|
|
69
128
|
muted: status.muted !== false,
|
|
70
129
|
tone: status.tone ?? "dark",
|
|
71
130
|
background: status.manifest?.background ?? status.background ?? null,
|
|
131
|
+
themeId: typeof status.themeId === "string" && status.themeId ? status.themeId : null,
|
|
72
132
|
message: formatStatusText(status),
|
|
73
133
|
};
|
|
74
134
|
}
|
|
@@ -106,24 +166,40 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
106
166
|
fail("beauticode_apply_image 只接受图片文件。");
|
|
107
167
|
}
|
|
108
168
|
const effects = effectsForPreset(options?.effects?.preset) || options?.effects || null;
|
|
169
|
+
const persistTheme = options?.persistTheme !== false;
|
|
170
|
+
const themeName =
|
|
171
|
+
String(options?.themeName ?? "").trim() ||
|
|
172
|
+
themeNameFromFilePath(inspected.path, "图片");
|
|
109
173
|
const input = { type: "image", imagePath: inspected.path };
|
|
110
174
|
if (effects) input.effects = effects;
|
|
111
175
|
const resolved = await backend();
|
|
112
176
|
if (resolved.kind === "tray") {
|
|
113
|
-
const body = { imagePath: inspected.path };
|
|
177
|
+
const body = { imagePath: inspected.path, persistTheme, themeName };
|
|
114
178
|
if (effects) body.effects = effects;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
179
|
+
const result = await request({
|
|
180
|
+
method: "POST",
|
|
181
|
+
path: "/apply/image",
|
|
182
|
+
body,
|
|
183
|
+
signal,
|
|
184
|
+
});
|
|
185
|
+
return unwrapApplyResult(
|
|
186
|
+
result,
|
|
122
187
|
"image",
|
|
123
|
-
|
|
188
|
+
persistTheme
|
|
189
|
+
? `已将「${result.theme?.name || themeName}」设为背景。`
|
|
190
|
+
: "已将图片设为背景。",
|
|
124
191
|
);
|
|
125
192
|
}
|
|
126
|
-
|
|
193
|
+
const applied = persistTheme
|
|
194
|
+
? await resolved.session.applyImportedMedia(input, { name: themeName })
|
|
195
|
+
: await resolved.session.apply(input);
|
|
196
|
+
return unwrapApplyResult(
|
|
197
|
+
applied,
|
|
198
|
+
"image",
|
|
199
|
+
persistTheme
|
|
200
|
+
? `已将「${applied.theme?.name || themeName}」设为背景。`
|
|
201
|
+
: "已将图片设为背景。",
|
|
202
|
+
);
|
|
127
203
|
},
|
|
128
204
|
|
|
129
205
|
async applyPreset(id, signal) {
|
|
@@ -132,6 +208,7 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
132
208
|
if (!preset || !imagePath) fail("未找到内置主题文件。");
|
|
133
209
|
const result = await this.applyImage(imagePath, signal, {
|
|
134
210
|
effects: effectsForPreset(id),
|
|
211
|
+
persistTheme: false,
|
|
135
212
|
});
|
|
136
213
|
try {
|
|
137
214
|
await this.setTone(preset.tone, signal);
|
|
@@ -167,7 +244,11 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
167
244
|
if (inspected.kind !== "video") {
|
|
168
245
|
fail("beauticode_apply_video 只接受 .mp4 文件。");
|
|
169
246
|
}
|
|
170
|
-
const
|
|
247
|
+
const persistTheme = input?.persistTheme !== false;
|
|
248
|
+
const themeName =
|
|
249
|
+
String(input?.themeName ?? "").trim() ||
|
|
250
|
+
themeNameFromFilePath(inspected.path, "视频");
|
|
251
|
+
const body = { videoPath: inspected.path, persistTheme, themeName };
|
|
171
252
|
const localInput = { type: "video", videoPath: inspected.path };
|
|
172
253
|
if (typeof input.poster === "string" && input.poster.trim()) {
|
|
173
254
|
const poster = await inspectLocalMedia(input.poster);
|
|
@@ -186,21 +267,29 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
186
267
|
}
|
|
187
268
|
const resolved = await backend();
|
|
188
269
|
if (resolved.kind === "tray") {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
270
|
+
const result = await request({
|
|
271
|
+
method: "POST",
|
|
272
|
+
path: "/apply/video",
|
|
273
|
+
body,
|
|
274
|
+
signal,
|
|
275
|
+
});
|
|
276
|
+
return unwrapApplyResult(
|
|
277
|
+
result,
|
|
196
278
|
"video",
|
|
197
|
-
|
|
279
|
+
persistTheme
|
|
280
|
+
? `已将「${result.theme?.name || themeName}」设为背景。`
|
|
281
|
+
: "已将视频设为背景。",
|
|
198
282
|
);
|
|
199
283
|
}
|
|
200
|
-
|
|
201
|
-
await resolved.session.
|
|
284
|
+
const applied = persistTheme
|
|
285
|
+
? await resolved.session.applyImportedMedia(localInput, { name: themeName })
|
|
286
|
+
: await resolved.session.apply(localInput);
|
|
287
|
+
return unwrapApplyResult(
|
|
288
|
+
applied,
|
|
202
289
|
"video",
|
|
203
|
-
|
|
290
|
+
persistTheme
|
|
291
|
+
? `已将「${applied.theme?.name || themeName}」设为背景。`
|
|
292
|
+
: "已将视频设为背景。",
|
|
204
293
|
);
|
|
205
294
|
},
|
|
206
295
|
|
|
@@ -311,6 +400,32 @@ export function createBeauticodeActions(dataRootOrOptions) {
|
|
|
311
400
|
};
|
|
312
401
|
},
|
|
313
402
|
|
|
403
|
+
async deleteTheme(id, signal) {
|
|
404
|
+
const themeId = String(id ?? "").trim();
|
|
405
|
+
if (!themeId) fail("必须提供主题。");
|
|
406
|
+
const resolved = await backend();
|
|
407
|
+
if (resolved.kind === "tray") {
|
|
408
|
+
const result = await request({
|
|
409
|
+
method: "POST",
|
|
410
|
+
path: "/theme/delete",
|
|
411
|
+
body: { id: themeId },
|
|
412
|
+
signal,
|
|
413
|
+
timeoutMs: QUICK_TIMEOUT_MS,
|
|
414
|
+
});
|
|
415
|
+
if (!result || result.ok === false) {
|
|
416
|
+
fail(await chineseError(result?.error || "删除主题失败。"));
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, deleted: true, message: "已删除主题。" };
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const deleted = await resolved.session.deleteSavedTheme(themeId);
|
|
422
|
+
if (!deleted) fail("未找到已保存的主题。");
|
|
423
|
+
return { ok: true, deleted: true, message: "已删除主题。" };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
fail(await chineseError(error));
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
|
|
314
429
|
async setFish(enabled, signal) {
|
|
315
430
|
const resolved = await backend();
|
|
316
431
|
const want = Boolean(enabled);
|
package/cli.js
CHANGED
|
@@ -78,19 +78,66 @@ function packageInsert() {
|
|
|
78
78
|
].join("\n");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function hasBridge(text) {
|
|
82
|
-
return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function stripBridge(text) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
function hasBridge(text) {
|
|
82
|
+
return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stripBridge(text) {
|
|
86
|
+
const lines = text.split(/\r?\n/);
|
|
87
|
+
const removed = new Array(lines.length).fill(false);
|
|
88
|
+
const indentOf = (line) => line.match(/^[ \t]*/)?.[0].length ?? 0;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
91
|
+
const insert = lines[i].match(/^([ \t]*)-\s*insert:\s*(?:#.*)?$/);
|
|
92
|
+
if (!insert) continue;
|
|
93
|
+
const insertIndent = insert[1].length;
|
|
94
|
+
let blockEnd = i + 1;
|
|
95
|
+
while (blockEnd < lines.length) {
|
|
96
|
+
const line = lines[blockEnd];
|
|
97
|
+
if (line.trim() && indentOf(line) <= insertIndent) break;
|
|
98
|
+
blockEnd += 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let foundBridge = false;
|
|
102
|
+
for (let j = i + 1; j < blockEnd; ) {
|
|
103
|
+
const bridge = lines[j].match(
|
|
104
|
+
/^([ \t]*)-\s*id:\s*beauticode-bridge\s*(?:#.*)?$/,
|
|
105
|
+
);
|
|
106
|
+
if (!bridge || bridge[1].length <= insertIndent) {
|
|
107
|
+
j += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
foundBridge = true;
|
|
111
|
+
const itemIndent = bridge[1].length;
|
|
112
|
+
let itemEnd = j + 1;
|
|
113
|
+
while (itemEnd < blockEnd) {
|
|
114
|
+
const line = lines[itemEnd];
|
|
115
|
+
if (line.trim() && indentOf(line) <= itemIndent) break;
|
|
116
|
+
itemEnd += 1;
|
|
117
|
+
}
|
|
118
|
+
for (let k = j; k < itemEnd; k += 1) removed[k] = true;
|
|
119
|
+
j = itemEnd;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!foundBridge) continue;
|
|
123
|
+
const marker = `${insert[1]}# beauticode-bridge (installer)`;
|
|
124
|
+
if (i > 0 && lines[i - 1].trimEnd() === marker) removed[i - 1] = true;
|
|
125
|
+
const hasSibling = lines
|
|
126
|
+
.slice(i + 1, blockEnd)
|
|
127
|
+
.some(
|
|
128
|
+
(line, offset) =>
|
|
129
|
+
!removed[i + 1 + offset] &&
|
|
130
|
+
line.trim() !== "" &&
|
|
131
|
+
!line.trimStart().startsWith("#"),
|
|
132
|
+
);
|
|
133
|
+
if (!hasSibling) {
|
|
134
|
+
for (let k = i; k < blockEnd; k += 1) removed[k] = true;
|
|
135
|
+
}
|
|
136
|
+
i = blockEnd - 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.filter((_, index) => !removed[index]).join("\n");
|
|
140
|
+
}
|
|
94
141
|
|
|
95
142
|
function overlayPayload(text) {
|
|
96
143
|
return text
|
|
@@ -196,6 +243,83 @@ async function ensureEngine(dest) {
|
|
|
196
243
|
await stageEngineInto(dest);
|
|
197
244
|
}
|
|
198
245
|
|
|
246
|
+
function pluginLinkPath(webProfile) {
|
|
247
|
+
return path.join(webProfile, "node_modules", pluginName);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function legacyPluginLinkPath(webProfile) {
|
|
251
|
+
return path.join(webProfile, "node_modules", "@beauticode", "dsh-plugin");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function linkSpecFor(pluginHome) {
|
|
255
|
+
return `link:${path.resolve(pluginHome).replaceAll("\\", "/")}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function sameLinkTarget(link, dest) {
|
|
259
|
+
try {
|
|
260
|
+
const stat = await fsp.lstat(link);
|
|
261
|
+
if (stat.isSymbolicLink()) {
|
|
262
|
+
const target = await fsp.readlink(link);
|
|
263
|
+
return path.resolve(path.dirname(link), target) === path.resolve(dest);
|
|
264
|
+
}
|
|
265
|
+
if (stat.isDirectory()) {
|
|
266
|
+
return path.resolve(link) === path.resolve(dest);
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function linkPluginIntoProfile(webProfile, dest) {
|
|
275
|
+
const link = pluginLinkPath(webProfile);
|
|
276
|
+
const legacy = legacyPluginLinkPath(webProfile);
|
|
277
|
+
if (fs.existsSync(legacy)) {
|
|
278
|
+
await fsp.rm(legacy, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
await fsp.mkdir(path.dirname(link), { recursive: true });
|
|
281
|
+
if (fs.existsSync(link)) {
|
|
282
|
+
if (await sameLinkTarget(link, dest)) return;
|
|
283
|
+
await fsp.rm(link, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
286
|
+
await fsp.symlink(path.resolve(dest), link, type);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function ensureWebPackageDep(webPackage, pluginHome) {
|
|
290
|
+
const raw = await fsp.readFile(webPackage, "utf8");
|
|
291
|
+
const json = JSON.parse(raw);
|
|
292
|
+
if (!json.dependencies || typeof json.dependencies !== "object" || Array.isArray(json.dependencies)) {
|
|
293
|
+
json.dependencies = {};
|
|
294
|
+
}
|
|
295
|
+
const spec = linkSpecFor(pluginHome);
|
|
296
|
+
const hadLegacy = Object.prototype.hasOwnProperty.call(
|
|
297
|
+
json.dependencies,
|
|
298
|
+
"@beauticode/dsh-plugin",
|
|
299
|
+
);
|
|
300
|
+
delete json.dependencies["@beauticode/dsh-plugin"];
|
|
301
|
+
const current = json.dependencies[pluginName];
|
|
302
|
+
if (current === spec && !hadLegacy) return;
|
|
303
|
+
json.dependencies[pluginName] = spec;
|
|
304
|
+
await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function removeWebPackageDep(webPackage) {
|
|
308
|
+
if (!fs.existsSync(webPackage)) return false;
|
|
309
|
+
const json = JSON.parse(await fsp.readFile(webPackage, "utf8"));
|
|
310
|
+
if (!json.dependencies || typeof json.dependencies !== "object") return false;
|
|
311
|
+
let changed = false;
|
|
312
|
+
for (const name of [pluginName, "@beauticode/dsh-plugin"]) {
|
|
313
|
+
if (Object.prototype.hasOwnProperty.call(json.dependencies, name)) {
|
|
314
|
+
delete json.dependencies[name];
|
|
315
|
+
changed = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!changed) return false;
|
|
319
|
+
await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8");
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
199
323
|
async function install(opts) {
|
|
200
324
|
const dest = path.resolve(opts.pluginHome);
|
|
201
325
|
const dshHome = path.resolve(opts.dshHome);
|
|
@@ -213,7 +337,9 @@ async function install(opts) {
|
|
|
213
337
|
}
|
|
214
338
|
|
|
215
339
|
if (fs.existsSync(webPackage)) {
|
|
216
|
-
await
|
|
340
|
+
await linkPluginIntoProfile(webProfile, dest);
|
|
341
|
+
await ensureWebPackageDep(webPackage, dest);
|
|
342
|
+
await writePatch(webPatch, packageInsert());
|
|
217
343
|
if (fs.existsSync(homePatch)) {
|
|
218
344
|
const homeRaw = await fsp.readFile(homePatch, "utf8");
|
|
219
345
|
if (hasBridge(homeRaw)) await removePatch(homePatch);
|
|
@@ -234,13 +360,23 @@ async function install(opts) {
|
|
|
234
360
|
|
|
235
361
|
async function uninstall(opts) {
|
|
236
362
|
const dshHome = path.resolve(opts.dshHome);
|
|
363
|
+
const webProfile = path.join(dshHome, "profiles", "web");
|
|
237
364
|
const removed = [];
|
|
238
|
-
if (await removePatch(path.join(
|
|
365
|
+
if (await removePatch(path.join(webProfile, "cordis.patch.yml"))) {
|
|
239
366
|
removed.push("web patch");
|
|
240
367
|
}
|
|
241
368
|
if (await removePatch(path.join(dshHome, "cordis.patch.yml"))) {
|
|
242
369
|
removed.push("home patch");
|
|
243
370
|
}
|
|
371
|
+
if (await removeWebPackageDep(path.join(webProfile, "package.json"))) {
|
|
372
|
+
removed.push("web package.json");
|
|
373
|
+
}
|
|
374
|
+
for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) {
|
|
375
|
+
if (fs.existsSync(link)) {
|
|
376
|
+
await fsp.rm(link, { recursive: true, force: true });
|
|
377
|
+
removed.push(link);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
244
380
|
const dest = path.resolve(opts.pluginHome);
|
|
245
381
|
if (fs.existsSync(dest)) {
|
|
246
382
|
await fsp.rm(dest, { recursive: true, force: true });
|
package/console.js
CHANGED
|
@@ -32,9 +32,13 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{ba
|
|
|
32
32
|
#beauticode-console-pop .bc-theme-toggle{width:100%;text-align:left}
|
|
33
33
|
#beauticode-console-pop .bc-theme-list{margin:6px 0 0;max-height:160px;overflow:auto;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:#232830;padding:4px}
|
|
34
34
|
body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8}
|
|
35
|
-
#beauticode-console-pop .bc-theme-
|
|
35
|
+
#beauticode-console-pop .bc-theme-row{display:flex;gap:4px;align-items:center}
|
|
36
|
+
#beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;flex:1;min-width:0;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
36
37
|
#beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)}
|
|
37
38
|
body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)}
|
|
39
|
+
#beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:32px;height:32px;padding:0;border:none;border-radius:8px;background:transparent;color:inherit;font-size:12px;opacity:.72}
|
|
40
|
+
#beauticode-console-pop .bc-theme-del:hover{background:rgba(255,255,255,.08);opacity:1}
|
|
41
|
+
body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{background:rgba(0,0,0,.06)}
|
|
38
42
|
#beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden}
|
|
39
43
|
#beauticode-console-file{display:none !important}
|
|
40
44
|
`;
|
|
@@ -154,7 +158,11 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
|
|
|
154
158
|
? "图片"
|
|
155
159
|
: "无背景";
|
|
156
160
|
statusEl.textContent = label;
|
|
157
|
-
if (data.
|
|
161
|
+
if (typeof data.themeId === "string" && data.themeId) {
|
|
162
|
+
currentThemeId = data.themeId;
|
|
163
|
+
} else if (data.atmosphere === "gallery") {
|
|
164
|
+
currentThemeId = "builtin-gallery";
|
|
165
|
+
}
|
|
158
166
|
muted = data.muted !== false;
|
|
159
167
|
soundBtn.classList.toggle("on", !muted);
|
|
160
168
|
soundBtn.textContent = muted ? "声音" : "声音开";
|
|
@@ -168,10 +176,13 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
|
|
|
168
176
|
}
|
|
169
177
|
themesBox.hidden = false;
|
|
170
178
|
themeList.innerHTML = themes
|
|
171
|
-
.map(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
.map((theme) => {
|
|
180
|
+
const del =
|
|
181
|
+
theme.bundled === true
|
|
182
|
+
? ""
|
|
183
|
+
: `<button type="button" class="bc-theme-del" data-theme-delete="${escapeAttr(theme.id)}" data-theme-name="${escapeAttr(theme.name)}" aria-label="删除 ${escapeAttr(theme.name)}">删</button>`;
|
|
184
|
+
return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}</button>${del}</div>`;
|
|
185
|
+
})
|
|
175
186
|
.join("");
|
|
176
187
|
const selected = themes.find((theme) => theme.id === currentThemeId);
|
|
177
188
|
themeToggle.textContent = selected ? selected.name : "已保存主题";
|
|
@@ -213,17 +224,18 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
|
|
|
213
224
|
async function run(task) {
|
|
214
225
|
if (busy) return;
|
|
215
226
|
busy = true;
|
|
216
|
-
for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = true;
|
|
227
|
+
for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true;
|
|
217
228
|
showMessage("");
|
|
218
229
|
try {
|
|
219
230
|
const result = await task();
|
|
231
|
+
if (result?.theme?.id) currentThemeId = result.theme.id;
|
|
220
232
|
if (result?.message) showMessage(result.message);
|
|
221
233
|
await refresh();
|
|
222
234
|
} catch (error) {
|
|
223
235
|
showMessage(error instanceof Error ? error.message : String(error));
|
|
224
236
|
} finally {
|
|
225
237
|
busy = false;
|
|
226
|
-
for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = false;
|
|
238
|
+
for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false;
|
|
227
239
|
}
|
|
228
240
|
}
|
|
229
241
|
|
|
@@ -268,6 +280,23 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
|
|
|
268
280
|
themeToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
269
281
|
});
|
|
270
282
|
themeList.addEventListener("click", (event) => {
|
|
283
|
+
const del = event.target.closest("[data-theme-delete]");
|
|
284
|
+
if (del) {
|
|
285
|
+
event.stopPropagation();
|
|
286
|
+
const id = del.getAttribute("data-theme-delete") || "";
|
|
287
|
+
const name = del.getAttribute("data-theme-name") || "主题";
|
|
288
|
+
if (!id) return;
|
|
289
|
+
if (!window.confirm(`确定删除主题「${name}」?`)) return;
|
|
290
|
+
if (currentThemeId === id) currentThemeId = "";
|
|
291
|
+
void run(() =>
|
|
292
|
+
request("/__beauticode/ui/theme/delete", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "content-type": "application/json" },
|
|
295
|
+
body: JSON.stringify({ id }),
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
271
300
|
const item = event.target.closest("[data-theme-id]");
|
|
272
301
|
if (!item) return;
|
|
273
302
|
currentThemeId = item.getAttribute("data-theme-id") || "";
|
|
@@ -288,7 +317,6 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{back
|
|
|
288
317
|
const file = fileInput.files?.[0];
|
|
289
318
|
fileInput.value = "";
|
|
290
319
|
if (!file) return;
|
|
291
|
-
currentThemeId = "";
|
|
292
320
|
void run(() =>
|
|
293
321
|
request("/__beauticode/ui/import", {
|
|
294
322
|
method: "POST",
|
package/index.mjs
CHANGED
|
@@ -376,6 +376,11 @@ export function apply(ctx, config = {}) {
|
|
|
376
376
|
path: "/__beauticode/ui/theme/use",
|
|
377
377
|
handler: (req, res) => ui.useTheme(req, res),
|
|
378
378
|
}),
|
|
379
|
+
ctx.webServer.register({
|
|
380
|
+
kind: "exact",
|
|
381
|
+
path: "/__beauticode/ui/theme/delete",
|
|
382
|
+
handler: (req, res) => ui.deleteTheme(req, res),
|
|
383
|
+
}),
|
|
379
384
|
ctx.webServer.register({
|
|
380
385
|
kind: "exact",
|
|
381
386
|
path: "/__beauticode/ui/preset",
|
package/package.json
CHANGED
package/ui-host.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { Transform } from "node:stream";
|
|
7
|
-
import { createBeauticodeActions } from "./agent.mjs";
|
|
7
|
+
import { createBeauticodeActions, themeNameFromFilePath } from "./agent.mjs";
|
|
8
8
|
import { callDshControl } from "./control-client.mjs";
|
|
9
9
|
import { createGalleryHandlers, resolveConfiguredSkinCenterUrl } from "./gallery-host.mjs";
|
|
10
10
|
import { hasLiveTray, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs";
|
|
@@ -146,6 +146,7 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
|
|
|
146
146
|
media: background?.type ?? null,
|
|
147
147
|
muted: status.muted !== false,
|
|
148
148
|
atmosphere: background?.effects?.preset ?? null,
|
|
149
|
+
themeId: status.themeId || null,
|
|
149
150
|
themes: publicThemes(listed.themes),
|
|
150
151
|
message: status.message,
|
|
151
152
|
skinCenter: { url: center, enabled: Boolean(center) },
|
|
@@ -187,10 +188,14 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
|
|
|
187
188
|
);
|
|
188
189
|
try {
|
|
189
190
|
await pipeline(req, limitBytes(parsed.maxBytes), fs.createWriteStream(tmpPath));
|
|
191
|
+
const themeName = themeNameFromFilePath(
|
|
192
|
+
parsed.name,
|
|
193
|
+
parsed.kind === "video" ? "视频" : "图片",
|
|
194
|
+
);
|
|
190
195
|
const result =
|
|
191
196
|
parsed.kind === "video"
|
|
192
|
-
? await actions.applyVideo({ path: tmpPath })
|
|
193
|
-
: await actions.applyImage(tmpPath);
|
|
197
|
+
? await actions.applyVideo({ path: tmpPath, themeName })
|
|
198
|
+
: await actions.applyImage(tmpPath, undefined, { themeName });
|
|
194
199
|
sendJson(res, 200, result);
|
|
195
200
|
} catch (error) {
|
|
196
201
|
const status = error?.statusCode === 413 ? 413 : 422;
|
|
@@ -270,6 +275,30 @@ export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, is
|
|
|
270
275
|
}
|
|
271
276
|
},
|
|
272
277
|
|
|
278
|
+
async deleteTheme(req, res) {
|
|
279
|
+
if (req.method !== "POST") {
|
|
280
|
+
res.writeHead(405).end();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!isSameOrigin(req)) {
|
|
284
|
+
res.writeHead(403).end();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const body = await readJson(req);
|
|
288
|
+
if (typeof body.id !== "string" || !body.id.trim()) {
|
|
289
|
+
sendJson(res, 400, { ok: false, error: "必须提供主题。" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
sendJson(res, 200, await actions.deleteTheme(body.id.trim()));
|
|
294
|
+
} catch (error) {
|
|
295
|
+
sendJson(res, 422, {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: error instanceof Error ? error.message : String(error),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
273
302
|
async galleryConfig(req, res) {
|
|
274
303
|
return gallery.config(req, res, sendJson, isSameOrigin);
|
|
275
304
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, resolveSessionBundledThemes, } from "../core/index.js";
|
|
3
|
+
import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, persistAndApplyImportedMedia, resolveSessionBundledThemes, } from "../core/index.js";
|
|
4
4
|
import { DshHostApplier, normalizeDshBaseUrl } from "./bridge.js";
|
|
5
5
|
import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js";
|
|
6
6
|
import { acquireDshInjectorLock } from "./injector-lock.js";
|
|
@@ -181,7 +181,11 @@ export class DshSession {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
if (manifest.background) {
|
|
184
|
-
|
|
184
|
+
const runtimeImagePath = await this.store.prepareRuntimeImage(manifest);
|
|
185
|
+
if (!runtimeImagePath) {
|
|
186
|
+
throw new Error("DSH image reapply requires a detached runtime copy.");
|
|
187
|
+
}
|
|
188
|
+
stagedImage = await this.media.stage(runtimeImagePath);
|
|
185
189
|
if (manifest.background.type === "video" && manifest.background.video) {
|
|
186
190
|
const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
|
|
187
191
|
if (!runtimeVideoPath) {
|
|
@@ -254,6 +258,7 @@ export class DshSession {
|
|
|
254
258
|
fish: this.fishMode,
|
|
255
259
|
muted: this.videoMuted,
|
|
256
260
|
tone: this.backgroundTone,
|
|
261
|
+
themeId: this.activeThemeId,
|
|
257
262
|
};
|
|
258
263
|
}
|
|
259
264
|
async saveCurrentTheme(name) {
|
|
@@ -270,18 +275,16 @@ export class DshSession {
|
|
|
270
275
|
}
|
|
271
276
|
}
|
|
272
277
|
const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.lastProgressWriteSec = -1;
|
|
276
|
-
}
|
|
277
|
-
else {
|
|
278
|
-
this.activeThemeId = null;
|
|
279
|
-
}
|
|
278
|
+
this.activeThemeId = theme.id;
|
|
279
|
+
this.lastProgressWriteSec = -1;
|
|
280
280
|
return theme;
|
|
281
281
|
}
|
|
282
282
|
async importSavedTheme(input) {
|
|
283
283
|
return this.store.importSavedThemeFromMedia(input);
|
|
284
284
|
}
|
|
285
|
+
async applyImportedMedia(input, opts = {}) {
|
|
286
|
+
return this.trackOperation(persistAndApplyImportedMedia(this, input, opts));
|
|
287
|
+
}
|
|
285
288
|
async listSavedThemes() {
|
|
286
289
|
return this.store.listSavedThemes();
|
|
287
290
|
}
|
|
@@ -298,7 +301,7 @@ export class DshSession {
|
|
|
298
301
|
const saved = await this.store.loadSavedTheme(themeId);
|
|
299
302
|
const result = await this.apply(saved.input);
|
|
300
303
|
if (result.ok) {
|
|
301
|
-
this.activeThemeId = saved.
|
|
304
|
+
this.activeThemeId = saved.themeId;
|
|
302
305
|
this.lastProgressWriteSec = -1;
|
|
303
306
|
}
|
|
304
307
|
return result;
|
|
@@ -103,7 +103,7 @@ export class ApplyTransaction {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
await this.media.commit(staged);
|
|
106
|
-
await this.store.pruneRuntimeMedia(runtimeVideoPath);
|
|
106
|
+
await this.store.pruneRuntimeMedia(runtimeKeepPaths(staged, runtimeVideoPath));
|
|
107
107
|
staged = null;
|
|
108
108
|
if (snapshot) {
|
|
109
109
|
await this.store.clearSnapshot(snapshot);
|
|
@@ -141,13 +141,15 @@ export class ApplyTransaction {
|
|
|
141
141
|
if (!manifest.background) {
|
|
142
142
|
return { image: null, video: null };
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
// Serve stills and videos from runtime-media copies so Windows can rotate
|
|
145
|
+
// `active/` while the previous generation is still being read.
|
|
146
|
+
const runtimeImagePath = await this.store.prepareRuntimeImage(manifest);
|
|
147
|
+
if (!runtimeImagePath) {
|
|
148
|
+
throw new Error("Image media staging requires a detached runtime copy.");
|
|
149
|
+
}
|
|
150
|
+
const image = await this.media.stage(runtimeImagePath);
|
|
146
151
|
let video = null;
|
|
147
152
|
if (manifest.background.type === "video" && manifest.background.video) {
|
|
148
|
-
// DSH streams this handle for the lifetime of the active <video>. On
|
|
149
|
-
// Windows, serving active/background.mp4 directly keeps the active
|
|
150
|
-
// directory open and the next atomic directory swap fails with EPERM.
|
|
151
153
|
const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
|
|
152
154
|
if (!runtimeVideoPath) {
|
|
153
155
|
throw new Error("Video media staging requires a detached runtime copy.");
|
|
@@ -188,7 +190,7 @@ export class ApplyTransaction {
|
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
await this.media.commit(restoredMedia);
|
|
191
|
-
await this.store.pruneRuntimeMedia(runtimeVideoPath);
|
|
193
|
+
await this.store.pruneRuntimeMedia(runtimeKeepPaths(restoredMedia, runtimeVideoPath));
|
|
192
194
|
restoredMedia = null;
|
|
193
195
|
await this.store.clearSnapshot(snapshot);
|
|
194
196
|
}
|
|
@@ -197,6 +199,16 @@ export class ApplyTransaction {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
}
|
|
202
|
+
function runtimeKeepPaths(staged, extra) {
|
|
203
|
+
const keep = [];
|
|
204
|
+
if (staged?.image?.filePath)
|
|
205
|
+
keep.push(staged.image.filePath);
|
|
206
|
+
if (staged?.video?.filePath)
|
|
207
|
+
keep.push(staged.video.filePath);
|
|
208
|
+
if (extra)
|
|
209
|
+
keep.push(extra);
|
|
210
|
+
return keep;
|
|
211
|
+
}
|
|
200
212
|
export async function buildHostApplyPayload(store, manifest, staged, cssText, videoStartAt) {
|
|
201
213
|
if (!manifest.background) {
|
|
202
214
|
return {
|
|
@@ -56,7 +56,7 @@ export class BackgroundStore {
|
|
|
56
56
|
maxSavedBytes;
|
|
57
57
|
bundledThemes;
|
|
58
58
|
#runtimeSessionName = `session-${process.pid}-${crypto.randomUUID()}`;
|
|
59
|
-
#
|
|
59
|
+
#runtimeMediaCache = new Map();
|
|
60
60
|
#writeChain = Promise.resolve();
|
|
61
61
|
#writeContext = new AsyncLocalStorage();
|
|
62
62
|
constructor(opts = {}) {
|
|
@@ -242,6 +242,24 @@ export class BackgroundStore {
|
|
|
242
242
|
this.#writeChain = run.then(() => undefined, () => undefined);
|
|
243
243
|
return run;
|
|
244
244
|
}
|
|
245
|
+
/**
|
|
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
|
+
}
|
|
245
263
|
/**
|
|
246
264
|
* Copy the active video to a renderer-only path outside `active`.
|
|
247
265
|
*
|
|
@@ -251,57 +269,73 @@ export class BackgroundStore {
|
|
|
251
269
|
* renderer lifetime independent from the disk transaction.
|
|
252
270
|
*/
|
|
253
271
|
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) {
|
|
254
285
|
return this.#withWriteLock(async () => {
|
|
255
|
-
if (manifest.background?.type !== "video" ||
|
|
256
|
-
!manifest.background.video) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
286
|
await this.init();
|
|
260
|
-
const
|
|
287
|
+
const cacheKey = `${opts.kind}:${opts.generation}`;
|
|
288
|
+
const cached = this.#runtimeMediaCache.get(cacheKey);
|
|
261
289
|
if (cached) {
|
|
262
290
|
try {
|
|
263
|
-
await
|
|
291
|
+
await opts.validate(cached);
|
|
264
292
|
return cached;
|
|
265
293
|
}
|
|
266
294
|
catch {
|
|
267
|
-
this.#
|
|
295
|
+
this.#runtimeMediaCache.delete(cacheKey);
|
|
268
296
|
}
|
|
269
297
|
}
|
|
270
|
-
const source = path.join(this.paths.activeDir,
|
|
271
|
-
const validated = await
|
|
272
|
-
const sessionDir =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
if (!runtimeRootStat.isDirectory() ||
|
|
278
|
-
runtimeRootStat.isSymbolicLink()) {
|
|
279
|
-
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
280
|
-
}
|
|
281
|
-
await fs.mkdir(sessionDir, { recursive: true });
|
|
282
|
-
const sessionStat = await fs.lstat(sessionDir);
|
|
283
|
-
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
284
|
-
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
285
|
-
}
|
|
286
|
-
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
287
|
-
fs.realpath(this.paths.runtimeMediaDir),
|
|
288
|
-
fs.realpath(sessionDir),
|
|
289
|
-
]);
|
|
290
|
-
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
291
|
-
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
292
|
-
}
|
|
293
|
-
const destination = path.join(sessionDir, `video-${manifest.generation}-${validated.identity.slice(0, 16)}.mp4`);
|
|
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}`);
|
|
294
305
|
await copyFileAtomic(validated.filePath, destination);
|
|
295
|
-
const copied = await
|
|
306
|
+
const copied = await opts.validate(destination);
|
|
296
307
|
if (copied.identity !== validated.identity) {
|
|
297
308
|
await fs.rm(destination, { force: true }).catch(() => { });
|
|
298
|
-
throw new MediaValidationError(
|
|
309
|
+
throw new MediaValidationError(`Runtime ${opts.kind} copy failed its identity check.`);
|
|
299
310
|
}
|
|
300
|
-
this.#
|
|
301
|
-
await this.#pruneRuntimeMediaUnlocked(
|
|
311
|
+
this.#runtimeMediaCache.set(cacheKey, destination);
|
|
312
|
+
await this.#pruneRuntimeMediaUnlocked([...this.#runtimeMediaCache.values()]);
|
|
302
313
|
return destination;
|
|
303
314
|
});
|
|
304
315
|
}
|
|
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
|
+
}
|
|
305
339
|
/**
|
|
306
340
|
* Best-effort cleanup. Locked stale files are retained and retried on the
|
|
307
341
|
* next apply/start instead of turning successful theme switching into an
|
|
@@ -310,14 +344,15 @@ export class BackgroundStore {
|
|
|
310
344
|
async pruneRuntimeMedia(keepPath = null) {
|
|
311
345
|
await this.#withWriteLock(async () => {
|
|
312
346
|
await this.init();
|
|
313
|
-
|
|
347
|
+
const keep = (Array.isArray(keepPath) ? keepPath : [keepPath]).filter((value) => typeof value === "string" && value.length > 0);
|
|
348
|
+
await this.#pruneRuntimeMediaUnlocked(keep);
|
|
314
349
|
});
|
|
315
350
|
}
|
|
316
|
-
async #pruneRuntimeMediaUnlocked(
|
|
351
|
+
async #pruneRuntimeMediaUnlocked(keepPaths) {
|
|
317
352
|
const runtimeRoot = path.resolve(this.paths.runtimeMediaDir);
|
|
318
|
-
const keep =
|
|
319
|
-
|
|
320
|
-
|
|
353
|
+
const keep = new Set(keepPaths
|
|
354
|
+
.filter((value) => isPathInsideRoot(runtimeRoot, value))
|
|
355
|
+
.map((value) => path.resolve(value)));
|
|
321
356
|
let sessions;
|
|
322
357
|
try {
|
|
323
358
|
sessions = await fs.readdir(runtimeRoot);
|
|
@@ -331,7 +366,8 @@ export class BackgroundStore {
|
|
|
331
366
|
const sessionDir = path.join(runtimeRoot, name);
|
|
332
367
|
if (!isPathInsideRoot(runtimeRoot, sessionDir))
|
|
333
368
|
continue;
|
|
334
|
-
|
|
369
|
+
const keepInSession = [...keep].some((file) => isPathInsideRoot(sessionDir, file));
|
|
370
|
+
if (!keepInSession) {
|
|
335
371
|
await rmrf(sessionDir).catch(() => { });
|
|
336
372
|
continue;
|
|
337
373
|
}
|
|
@@ -344,15 +380,15 @@ export class BackgroundStore {
|
|
|
344
380
|
}
|
|
345
381
|
for (const file of files) {
|
|
346
382
|
const candidate = path.resolve(sessionDir, file);
|
|
347
|
-
if (candidate
|
|
383
|
+
if (keep.has(candidate) || !isPathInsideRoot(sessionDir, candidate)) {
|
|
348
384
|
continue;
|
|
349
385
|
}
|
|
350
386
|
await fs.rm(candidate, { recursive: true, force: true }).catch(() => { });
|
|
351
387
|
}
|
|
352
388
|
}
|
|
353
|
-
for (const [
|
|
354
|
-
if (path.resolve(cached)
|
|
355
|
-
this.#
|
|
389
|
+
for (const [key, cached] of this.#runtimeMediaCache) {
|
|
390
|
+
if (!keep.has(path.resolve(cached))) {
|
|
391
|
+
this.#runtimeMediaCache.delete(key);
|
|
356
392
|
}
|
|
357
393
|
}
|
|
358
394
|
}
|
|
@@ -671,15 +707,32 @@ export class BackgroundStore {
|
|
|
671
707
|
if (existing)
|
|
672
708
|
return existing;
|
|
673
709
|
}
|
|
674
|
-
const
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
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
|
+
}
|
|
678
731
|
const usage = await this.#savedThemeUsage();
|
|
679
732
|
if (usage.count >= this.maxSavedThemes) {
|
|
680
733
|
throw new MediaValidationError(`Saved theme limit reached (${this.maxSavedThemes}). Delete one before saving.`);
|
|
681
734
|
}
|
|
682
|
-
const incomingBytes = image
|
|
735
|
+
const incomingBytes = (image?.size ?? SYNTHETIC_POSTER_PNG.length) + (video?.size ?? 0);
|
|
683
736
|
if (usage.bytes + incomingBytes > this.maxSavedBytes) {
|
|
684
737
|
throw new MediaValidationError(`Saved theme storage limit exceeded (${this.maxSavedBytes} bytes). Delete a theme before saving.`);
|
|
685
738
|
}
|
|
@@ -690,9 +743,17 @@ export class BackgroundStore {
|
|
|
690
743
|
}
|
|
691
744
|
const stagingDir = await this.#createStagingDir();
|
|
692
745
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
+
}
|
|
696
757
|
const background = video
|
|
697
758
|
? {
|
|
698
759
|
type: "video",
|
|
@@ -724,6 +785,13 @@ export class BackgroundStore {
|
|
|
724
785
|
};
|
|
725
786
|
if (source)
|
|
726
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
|
+
}
|
|
727
795
|
await this.#writeJsonAtomic(path.join(stagingDir, SAVED_META_NAME), meta);
|
|
728
796
|
await this.#validateTree(stagingDir, savedManifest);
|
|
729
797
|
await fs.rename(stagingDir, dir);
|
|
@@ -1158,6 +1226,70 @@ export class BackgroundStore {
|
|
|
1158
1226
|
return { count, bytes };
|
|
1159
1227
|
}
|
|
1160
1228
|
}
|
|
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
|
+
}
|
|
1161
1293
|
/** Normalize a playback position for theme meta. Invalid → null (caller treats as 0). */
|
|
1162
1294
|
export function normalizeVideoPositionSec(value) {
|
|
1163
1295
|
const n = typeof value === "number" ? value : Number(value);
|
|
@@ -126,6 +126,8 @@ export function toChineseErrorMessage(value) {
|
|
|
126
126
|
[/No active background to save\.?/i, "当前没有可保存的背景。"],
|
|
127
127
|
[/Saved theme limit reached \((\d+)\)\. Delete one before saving\.?/i, (match) => `已达到保存主题数量上限(${match[1]}),请先删除一个主题。`],
|
|
128
128
|
[/Saved theme storage limit exceeded \((\d+) bytes\)\. Delete a theme before saving\.?/i, (match) => `已超过保存主题的存储上限(${match[1]} 字节),请先删除一个主题。`],
|
|
129
|
+
[/Import requires an image or video file\.?/i, "导入需要图片或视频文件。"],
|
|
130
|
+
[/Imported media must be an image or video\.?/i, "导入的媒体必须是图片或视频。"],
|
|
129
131
|
[/Built-in theme cannot be deleted\.?/i, "内置主题不能删除。"],
|
|
130
132
|
[/Invalid saved theme id\.?/i, "已保存主题 ID 无效。"],
|
|
131
133
|
[/Invalid skin-center media path\.?/i, "皮肤中心地址无效。"],
|
|
@@ -36,6 +36,27 @@ function parseRange(value, size) {
|
|
|
36
36
|
function makeToken() {
|
|
37
37
|
return randomUUID().replaceAll("-", "");
|
|
38
38
|
}
|
|
39
|
+
function pipeOpenedFile(handle, response, range) {
|
|
40
|
+
const stream = handle.createReadStream({
|
|
41
|
+
start: range.start,
|
|
42
|
+
...(range.end != null ? { end: range.end } : {}),
|
|
43
|
+
autoClose: false,
|
|
44
|
+
});
|
|
45
|
+
let closed = false;
|
|
46
|
+
const closeHandle = () => {
|
|
47
|
+
if (closed)
|
|
48
|
+
return;
|
|
49
|
+
closed = true;
|
|
50
|
+
void handle.close().catch(() => { });
|
|
51
|
+
};
|
|
52
|
+
stream.on("error", () => {
|
|
53
|
+
closeHandle();
|
|
54
|
+
response.destroy();
|
|
55
|
+
});
|
|
56
|
+
stream.on("close", closeHandle);
|
|
57
|
+
response.on("close", closeHandle);
|
|
58
|
+
stream.pipe(response);
|
|
59
|
+
}
|
|
39
60
|
async function hashOpenedFile(handle, size) {
|
|
40
61
|
const hash = createHash("sha256");
|
|
41
62
|
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
@@ -401,11 +422,8 @@ export class LoopbackMediaHub {
|
|
|
401
422
|
opened = null;
|
|
402
423
|
return;
|
|
403
424
|
}
|
|
404
|
-
|
|
425
|
+
pipeOpenedFile(opened, response, { start: 0 });
|
|
405
426
|
opened = null;
|
|
406
|
-
stream
|
|
407
|
-
.on("error", () => response.destroy())
|
|
408
|
-
.pipe(response);
|
|
409
427
|
return;
|
|
410
428
|
}
|
|
411
429
|
headers["Content-Range"] =
|
|
@@ -418,15 +436,11 @@ export class LoopbackMediaHub {
|
|
|
418
436
|
opened = null;
|
|
419
437
|
return;
|
|
420
438
|
}
|
|
421
|
-
|
|
439
|
+
pipeOpenedFile(opened, response, {
|
|
422
440
|
start: range.start,
|
|
423
441
|
end: range.end,
|
|
424
|
-
autoClose: true,
|
|
425
442
|
});
|
|
426
443
|
opened = null;
|
|
427
|
-
stream
|
|
428
|
-
.on("error", () => response.destroy())
|
|
429
|
-
.pipe(response);
|
|
430
444
|
}
|
|
431
445
|
catch {
|
|
432
446
|
await opened?.close().catch(() => { });
|