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.
Files changed (40) hide show
  1. package/README.zh-CN.md +48 -0
  2. package/agent.mjs +595 -0
  3. package/atmosphere.js +306 -0
  4. package/bin/beauticode-dsh +219 -0
  5. package/cli.js +219 -0
  6. package/client.js +370 -0
  7. package/console.js +303 -0
  8. package/control-client.mjs +445 -0
  9. package/cordis.patch.yml +4 -0
  10. package/host-apply.mjs +163 -0
  11. package/index.mjs +570 -0
  12. package/package.json +56 -0
  13. package/presets.mjs +73 -0
  14. package/themes/internal-beyond/NOTICE.md +9 -0
  15. package/themes/internal-beyond/bg-canvas-4k.png +0 -0
  16. package/themes/internal-beyond/bg-canvas.png +0 -0
  17. package/themes/internal-beyond/bg-infernal.jpg +0 -0
  18. package/themes/internal-beyond/bg-internal.jpg +0 -0
  19. package/ui-host.mjs +292 -0
  20. package/vendor/adapter-dsh/bridge.js +215 -0
  21. package/vendor/adapter-dsh/host-descriptor.js +15 -0
  22. package/vendor/adapter-dsh/index.js +6 -0
  23. package/vendor/adapter-dsh/injector-lock.js +27 -0
  24. package/vendor/adapter-dsh/package.json +5 -0
  25. package/vendor/adapter-dsh/session.js +527 -0
  26. package/vendor/adapter-dsh/token.js +46 -0
  27. package/vendor/adapter-dsh/tray-handoff.js +54 -0
  28. package/vendor/core/apply-transaction.js +287 -0
  29. package/vendor/core/background-store.js +1116 -0
  30. package/vendor/core/bundled-gallery.js +53 -0
  31. package/vendor/core/constants.js +47 -0
  32. package/vendor/core/error-message.js +208 -0
  33. package/vendor/core/file-lock.js +151 -0
  34. package/vendor/core/host-session.js +2 -0
  35. package/vendor/core/index.js +12 -0
  36. package/vendor/core/media-server.js +544 -0
  37. package/vendor/core/media-validation.js +244 -0
  38. package/vendor/core/package.json +5 -0
  39. package/vendor/core/paths.js +168 -0
  40. package/vendor/core/types.js +15 -0
@@ -0,0 +1,445 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const CONTROL_SCHEMA = "beauticode.dsh-control/v1";
7
+ export const CONTROL_FILE = "dsh-control.json";
8
+ export const SESSION_HOST_SCHEMA = "beauticode.session-host/v1";
9
+ export const SESSION_HOST_FILE = "session-host.json";
10
+ export const TRAY_CLAIM_SCHEMA = "beauticode.tray-claim/v1";
11
+ export const TRAY_CLAIM_FILE = "tray-claim.json";
12
+ export const TRAY_MISSING_MESSAGE =
13
+ "未找到正在运行的 beautiCode 托盘。请先启动 beautiCode,再导入背景。";
14
+ export const TRAY_STARTING_MESSAGE = "beautiCode 托盘正在启动,请稍后再试。";
15
+
16
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
17
+ const TOKEN_MIN_LENGTH = 24;
18
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
19
+
20
+ export function defaultBeauticodeDataRoot() {
21
+ if (process.env.BEAUTICODE_DATA_ROOT) {
22
+ return path.resolve(process.env.BEAUTICODE_DATA_ROOT);
23
+ }
24
+ if (process.env.LOCALAPPDATA) {
25
+ return path.join(process.env.LOCALAPPDATA, "beautiCode");
26
+ }
27
+ return path.join(os.homedir(), ".beauticode");
28
+ }
29
+
30
+ export function controlFilePath(dataRoot) {
31
+ return path.join(path.resolve(dataRoot), CONTROL_FILE);
32
+ }
33
+
34
+ export function sessionHostFilePath(dataRoot) {
35
+ return path.join(path.resolve(dataRoot), SESSION_HOST_FILE);
36
+ }
37
+
38
+ export function trayClaimFilePath(dataRoot) {
39
+ return path.join(path.resolve(dataRoot), TRAY_CLAIM_FILE);
40
+ }
41
+
42
+ async function writeAtomicJson(dataRoot, fileName, payload) {
43
+ await fs.mkdir(dataRoot, { recursive: true });
44
+ const file = path.join(dataRoot, fileName);
45
+ const tmp = path.join(
46
+ dataRoot,
47
+ `.${fileName}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`,
48
+ );
49
+ const handle = await fs.open(tmp, "w", 0o600);
50
+ try {
51
+ await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
52
+ await handle.sync();
53
+ } finally {
54
+ await handle.close();
55
+ }
56
+ try {
57
+ await fs.unlink(file);
58
+ } catch (error) {
59
+ if (error && typeof error === "object" && error.code !== "ENOENT") throw error;
60
+ }
61
+ await fs.rename(tmp, file);
62
+ return file;
63
+ }
64
+
65
+ export function isLoopbackControlUrl(value) {
66
+ if (typeof value !== "string") return false;
67
+ try {
68
+ const url = new URL(value);
69
+ return (
70
+ url.protocol === "http:" &&
71
+ LOOPBACK_HOSTS.has(url.hostname.toLowerCase()) &&
72
+ !url.username &&
73
+ !url.password &&
74
+ (url.pathname === "" || url.pathname === "/") &&
75
+ !url.search &&
76
+ !url.hash
77
+ );
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function isPidAlive(pid) {
84
+ if (!Number.isInteger(pid) || pid <= 0) return false;
85
+ try {
86
+ process.kill(pid, 0);
87
+ return true;
88
+ } catch (error) {
89
+ return error && typeof error === "object" && error.code === "EPERM";
90
+ }
91
+ }
92
+
93
+ export function stripPathQuotes(value) {
94
+ const text = String(value ?? "").trim();
95
+ if (text.length >= 2) {
96
+ const first = text[0];
97
+ const last = text[text.length - 1];
98
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
99
+ return text.slice(1, -1).trim();
100
+ }
101
+ }
102
+ return text;
103
+ }
104
+
105
+ export async function inspectLocalMedia(filePath) {
106
+ const raw = stripPathQuotes(filePath);
107
+ if (!raw) return { ok: false, error: "必须提供文件路径。" };
108
+ if (!path.isAbsolute(raw)) {
109
+ return { ok: false, error: "请使用本机绝对路径。" };
110
+ }
111
+ const resolved = path.resolve(raw);
112
+ try {
113
+ const stat = await fs.lstat(resolved);
114
+ if (!stat.isFile() || stat.isSymbolicLink()) {
115
+ return { ok: false, error: "路径必须是普通文件,不能是目录或符号链接。" };
116
+ }
117
+ } catch {
118
+ return { ok: false, error: `找不到文件:${resolved}。请使用本机绝对路径。` };
119
+ }
120
+ const ext = path.extname(resolved).toLowerCase();
121
+ if (ext === ".mp4") return { ok: true, kind: "video", path: resolved };
122
+ if (IMAGE_EXTENSIONS.has(ext)) return { ok: true, kind: "image", path: resolved };
123
+ return {
124
+ ok: false,
125
+ error: "只支持图片(jpg / jpeg / png / webp / avif)或 MP4 视频。",
126
+ };
127
+ }
128
+
129
+ export function matchSavedTheme(themes, query) {
130
+ const needle = String(query ?? "").trim();
131
+ if (!needle) return { ok: false, error: "必须提供主题名称或 ID。" };
132
+ const list = Array.isArray(themes) ? themes : [];
133
+ const byId = list.find((theme) => theme.id === needle);
134
+ if (byId) return { ok: true, theme: byId };
135
+ const exactName = list.filter((theme) => theme.name === needle);
136
+ if (exactName.length === 1) return { ok: true, theme: exactName[0] };
137
+ const lower = needle.toLowerCase();
138
+ const ciName = list.filter((theme) => String(theme.name).toLowerCase() === lower);
139
+ if (ciName.length === 1) return { ok: true, theme: ciName[0] };
140
+ const prefixes = list.filter(
141
+ (theme) =>
142
+ String(theme.name).toLowerCase().startsWith(lower) ||
143
+ String(theme.id).toLowerCase().startsWith(lower),
144
+ );
145
+ if (prefixes.length === 1) return { ok: true, theme: prefixes[0] };
146
+ if (prefixes.length > 1) {
147
+ return {
148
+ ok: false,
149
+ error: `有多个主题匹配「${needle}」:${prefixes.map((theme) => theme.name).join("、")}。`,
150
+ };
151
+ }
152
+ if (list.length === 0) return { ok: false, error: "还没有已保存的主题。" };
153
+ return {
154
+ ok: false,
155
+ error: `未找到主题「${needle}」。已保存:${list.map((theme) => theme.name).join("、")}。`,
156
+ };
157
+ }
158
+
159
+ export function formatStatusText(status) {
160
+ const background = status?.manifest?.background;
161
+ let media = "无";
162
+ if (background?.type === "video") media = "视频";
163
+ else if (background?.type === "image") media = "图片";
164
+ const pages = Number.isInteger(status?.sessions) ? status.sessions : 0;
165
+ const fish = status?.fish ? "开" : "关";
166
+ const sound = status?.muted === false ? "开" : "关";
167
+ const tone = status?.tone || "dark";
168
+ const ready = status?.hostReady ? "已连接" : "未就绪";
169
+ return [
170
+ `背景:${media}`,
171
+ `页面:${ready}(${pages})`,
172
+ `摸鱼:${fish}`,
173
+ `声音:${sound}`,
174
+ `色调:${tone}`,
175
+ ].join(" · ");
176
+ }
177
+
178
+ export async function writeDshControlFile(opts) {
179
+ const dataRoot = path.resolve(opts.dataRoot);
180
+ const url = opts.url;
181
+ const token = opts.token;
182
+ const pid = opts.pid ?? process.pid;
183
+ if (!isLoopbackControlUrl(url)) {
184
+ throw new Error("DSH control URL must be loopback HTTP.");
185
+ }
186
+ if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) {
187
+ throw new Error("DSH control token is too short.");
188
+ }
189
+ if (!Number.isInteger(pid) || pid <= 0) {
190
+ throw new Error("DSH control pid is invalid.");
191
+ }
192
+ return writeAtomicJson(dataRoot, CONTROL_FILE, {
193
+ schema: CONTROL_SCHEMA,
194
+ host: "dsh",
195
+ pid,
196
+ url: new URL(url).origin,
197
+ token,
198
+ });
199
+ }
200
+
201
+ export async function writeSessionHostFile(opts) {
202
+ const dataRoot = path.resolve(opts.dataRoot);
203
+ const url = opts.url;
204
+ const token = opts.token;
205
+ const pid = opts.pid ?? process.pid;
206
+ const host = opts.host === "codex" ? "codex" : "dsh";
207
+ if (!isLoopbackControlUrl(url)) {
208
+ throw new Error("session-host URL must be loopback HTTP.");
209
+ }
210
+ if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) {
211
+ throw new Error("session-host token is too short.");
212
+ }
213
+ if (!Number.isInteger(pid) || pid <= 0) {
214
+ throw new Error("session-host pid is invalid.");
215
+ }
216
+ return writeAtomicJson(dataRoot, SESSION_HOST_FILE, {
217
+ schema: SESSION_HOST_SCHEMA,
218
+ host,
219
+ pid,
220
+ url: new URL(url).origin,
221
+ token,
222
+ });
223
+ }
224
+
225
+ export async function writeTrayClaim(opts) {
226
+ const dataRoot = path.resolve(opts.dataRoot);
227
+ const pid = opts.pid ?? process.pid;
228
+ if (!Number.isInteger(pid) || pid <= 0) {
229
+ throw new Error("tray claim pid is invalid.");
230
+ }
231
+ return writeAtomicJson(dataRoot, TRAY_CLAIM_FILE, {
232
+ schema: TRAY_CLAIM_SCHEMA,
233
+ pid,
234
+ startedAt: new Date().toISOString(),
235
+ });
236
+ }
237
+
238
+ export async function removeDshControlFile(opts) {
239
+ const dataRoot = path.resolve(opts.dataRoot);
240
+ const pid = opts.pid ?? process.pid;
241
+ const file = controlFilePath(dataRoot);
242
+ try {
243
+ const current = await readDshControlFile(dataRoot, { allowDead: true });
244
+ if (!current || current.pid !== pid) return false;
245
+ await fs.unlink(file);
246
+ return true;
247
+ } catch (error) {
248
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ export async function readDshControlFile(dataRoot, opts = {}) {
254
+ const file = controlFilePath(dataRoot);
255
+ let raw;
256
+ try {
257
+ raw = await fs.readFile(file, "utf8");
258
+ } catch (error) {
259
+ if (error && typeof error === "object" && error.code === "ENOENT") return null;
260
+ throw error;
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(raw);
265
+ } catch {
266
+ return null;
267
+ }
268
+ if (
269
+ !parsed ||
270
+ parsed.schema !== CONTROL_SCHEMA ||
271
+ parsed.host !== "dsh" ||
272
+ typeof parsed.token !== "string" ||
273
+ parsed.token.length < TOKEN_MIN_LENGTH ||
274
+ !isLoopbackControlUrl(parsed.url) ||
275
+ !Number.isInteger(parsed.pid) ||
276
+ parsed.pid <= 0
277
+ ) {
278
+ return null;
279
+ }
280
+ if (!opts.allowDead && !isPidAlive(parsed.pid)) return null;
281
+ return {
282
+ schema: CONTROL_SCHEMA,
283
+ host: "dsh",
284
+ pid: parsed.pid,
285
+ url: new URL(parsed.url).origin,
286
+ token: parsed.token,
287
+ };
288
+ }
289
+
290
+ export async function removeSessionHostFile(opts) {
291
+ const dataRoot = path.resolve(opts.dataRoot);
292
+ const pid = opts.pid ?? process.pid;
293
+ const file = sessionHostFilePath(dataRoot);
294
+ try {
295
+ const current = await readSessionHostFile(dataRoot, { allowDead: true });
296
+ if (!current || current.pid !== pid) return false;
297
+ await fs.unlink(file);
298
+ return true;
299
+ } catch (error) {
300
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ export async function readSessionHostFile(dataRoot, opts = {}) {
306
+ const file = sessionHostFilePath(dataRoot);
307
+ let raw;
308
+ try {
309
+ raw = await fs.readFile(file, "utf8");
310
+ } catch (error) {
311
+ if (error && typeof error === "object" && error.code === "ENOENT") return null;
312
+ throw error;
313
+ }
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(raw);
317
+ } catch {
318
+ return null;
319
+ }
320
+ if (
321
+ !parsed ||
322
+ parsed.schema !== SESSION_HOST_SCHEMA ||
323
+ (parsed.host !== "dsh" && parsed.host !== "codex") ||
324
+ typeof parsed.token !== "string" ||
325
+ parsed.token.length < TOKEN_MIN_LENGTH ||
326
+ !isLoopbackControlUrl(parsed.url) ||
327
+ !Number.isInteger(parsed.pid) ||
328
+ parsed.pid <= 0
329
+ ) {
330
+ return null;
331
+ }
332
+ if (!opts.allowDead && !isPidAlive(parsed.pid)) return null;
333
+ return {
334
+ schema: SESSION_HOST_SCHEMA,
335
+ host: parsed.host,
336
+ pid: parsed.pid,
337
+ url: new URL(parsed.url).origin,
338
+ token: parsed.token,
339
+ };
340
+ }
341
+
342
+ export async function readTrayClaim(dataRoot, opts = {}) {
343
+ const file = trayClaimFilePath(dataRoot);
344
+ let raw;
345
+ try {
346
+ raw = await fs.readFile(file, "utf8");
347
+ } catch (error) {
348
+ if (error && typeof error === "object" && error.code === "ENOENT") return null;
349
+ throw error;
350
+ }
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(raw);
354
+ } catch {
355
+ return null;
356
+ }
357
+ if (
358
+ !parsed ||
359
+ parsed.schema !== TRAY_CLAIM_SCHEMA ||
360
+ !Number.isInteger(parsed.pid) ||
361
+ parsed.pid <= 0
362
+ ) {
363
+ return null;
364
+ }
365
+ if (!opts.allowDead && !isPidAlive(parsed.pid)) return null;
366
+ return {
367
+ schema: TRAY_CLAIM_SCHEMA,
368
+ pid: parsed.pid,
369
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null,
370
+ };
371
+ }
372
+
373
+ export async function removeTrayClaim(opts) {
374
+ const dataRoot = path.resolve(opts.dataRoot);
375
+ const pid = opts.pid ?? process.pid;
376
+ const file = trayClaimFilePath(dataRoot);
377
+ try {
378
+ const current = await readTrayClaim(dataRoot, { allowDead: true });
379
+ if (!current || current.pid !== pid) return false;
380
+ await fs.unlink(file);
381
+ return true;
382
+ } catch (error) {
383
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
384
+ throw error;
385
+ }
386
+ }
387
+
388
+ function mergeSignals(userSignal, timeoutMs) {
389
+ const timeout = AbortSignal.timeout(timeoutMs);
390
+ if (!userSignal) return timeout;
391
+ return AbortSignal.any([userSignal, timeout]);
392
+ }
393
+
394
+ export async function callDshControl(dataRoot, spec) {
395
+ const control = await readDshControlFile(dataRoot);
396
+ if (!control) {
397
+ const error = new Error(TRAY_MISSING_MESSAGE);
398
+ error.code = "TRAY_MISSING";
399
+ throw error;
400
+ }
401
+ const timeoutMs = spec.timeoutMs ?? 180_000;
402
+ const signal = mergeSignals(spec.signal, timeoutMs);
403
+ if (typeof spec.path !== "string" || !spec.path.startsWith("/") || spec.path.startsWith("//")) {
404
+ throw new Error("DSH control path is invalid.");
405
+ }
406
+ const url = new URL(spec.path, `${control.url}/`);
407
+ if (!isLoopbackControlUrl(control.url) || url.origin !== control.url) {
408
+ throw new Error("DSH control URL must be loopback HTTP.");
409
+ }
410
+ let response;
411
+ try {
412
+ response = await fetch(url, {
413
+ method: spec.method,
414
+ headers: {
415
+ authorization: `Bearer ${control.token}`,
416
+ ...(spec.body !== undefined ? { "content-type": "application/json" } : {}),
417
+ },
418
+ body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
419
+ signal,
420
+ });
421
+ } catch (error) {
422
+ if (error && typeof error === "object" && error.name === "AbortError") {
423
+ throw new Error("导入已取消或超时。");
424
+ }
425
+ const detail = error instanceof Error ? error.message : String(error);
426
+ throw new Error(`无法连接 beautiCode 托盘(${detail})。`);
427
+ }
428
+ let payload = null;
429
+ try {
430
+ payload = await response.json();
431
+ } catch {
432
+ throw new Error(`托盘返回了无法解析的响应(HTTP ${response.status})。`);
433
+ }
434
+ if (!response.ok || payload?.ok === false) {
435
+ const message =
436
+ typeof payload?.error === "string"
437
+ ? payload.error
438
+ : `托盘请求失败(HTTP ${response.status})。`;
439
+ const error = new Error(message);
440
+ error.statusCode = response.status;
441
+ error.payload = payload;
442
+ throw error;
443
+ }
444
+ return payload;
445
+ }
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: beauticode-bridge
3
+ name: 'beauticode-dsh'
4
+ inject: [webServer]
package/host-apply.mjs ADDED
@@ -0,0 +1,163 @@
1
+ import path from "node:path";
2
+ import {
3
+ TRAY_STARTING_MESSAGE,
4
+ callDshControl,
5
+ readDshControlFile,
6
+ readTrayClaim,
7
+ } from "./control-client.mjs";
8
+ import { canvasImagePath } from "./presets.mjs";
9
+
10
+ const sessions = new Map();
11
+
12
+ export const ENGINE_MISSING_MESSAGE =
13
+ "beautiCode 插件未能加载本机导入引擎。请执行 npx @beauticode/dsh-plugin,或从完整安装目录加载桥接。";
14
+
15
+ export function resolvePluginBaseUrl(ctx) {
16
+ const port = ctx?.webServer?.port;
17
+ if (Number.isInteger(port) && port > 0 && port <= 65535) {
18
+ return `http://127.0.0.1:${port}`;
19
+ }
20
+ const configured = ctx?.webServer?.host;
21
+ if (configured && typeof ctx.webServer.port === "number") {
22
+ return `http://127.0.0.1:${ctx.webServer.port}`;
23
+ }
24
+ return "http://127.0.0.1:3080";
25
+ }
26
+
27
+ function sessionKey(dataRoot) {
28
+ return path.resolve(dataRoot);
29
+ }
30
+
31
+ export async function loadAdapter() {
32
+ const errors = [];
33
+ for (const specifier of [
34
+ new URL("./vendor/adapter-dsh/index.js", import.meta.url).href,
35
+ "@beauticode/adapter-dsh",
36
+ new URL("../../packages/adapter-dsh/dist/index.js", import.meta.url).href,
37
+ ]) {
38
+ try {
39
+ return await import(specifier);
40
+ } catch (error) {
41
+ errors.push(`${specifier}: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ }
44
+ const failure = new Error(`${ENGINE_MISSING_MESSAGE}(${errors.join(";")})`);
45
+ failure.code = "ENGINE_MISSING";
46
+ throw failure;
47
+ }
48
+
49
+ export async function hasLiveTray(dataRoot) {
50
+ const control = await readDshControlFile(dataRoot);
51
+ if (!control) return false;
52
+ try {
53
+ await callDshControl(dataRoot, {
54
+ method: "GET",
55
+ path: "/health",
56
+ timeoutMs: 2_000,
57
+ });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export async function hasLiveTrayClaim(dataRoot) {
65
+ return Boolean(await readTrayClaim(dataRoot));
66
+ }
67
+
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+
72
+ export async function ensureInProcessSession(options) {
73
+ const dataRoot = path.resolve(options.dataRoot);
74
+ const baseUrl = options.baseUrl || "http://127.0.0.1:3080";
75
+ const key = sessionKey(dataRoot);
76
+ const current = sessions.get(key);
77
+ if (current?.session && !current.session.isOpen) {
78
+ sessions.delete(key);
79
+ } else if (current?.session?.isOpen) {
80
+ return current.session;
81
+ } else if (current?.promise) {
82
+ return current.promise;
83
+ }
84
+
85
+ const promise = (async () => {
86
+ if (await hasLiveTray(dataRoot) || await hasLiveTrayClaim(dataRoot)) {
87
+ const error = new Error(TRAY_STARTING_MESSAGE);
88
+ error.code = "TRAY_CLAIMED";
89
+ throw error;
90
+ }
91
+ const adapter = await loadAdapter();
92
+ const session = new adapter.DshSession({
93
+ dataRoot,
94
+ baseUrl,
95
+ verifyDeadlineMs: 30_000,
96
+ bundledGalleryImagePath: canvasImagePath() || undefined,
97
+ });
98
+ try {
99
+ await session.start();
100
+ } catch (error) {
101
+ const message = adapter.toChineseErrorMessage(error);
102
+ throw new Error(message);
103
+ }
104
+ const stored = sessions.get(key);
105
+ if (stored) stored.session = session;
106
+ return session;
107
+ })();
108
+
109
+ sessions.set(key, { session: null, promise });
110
+ try {
111
+ return await promise;
112
+ } catch (error) {
113
+ sessions.delete(key);
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ export async function stopInProcessSession(dataRoot) {
119
+ if (dataRoot == null) {
120
+ const all = [...sessions.keys()];
121
+ await Promise.all(all.map((key) => stopInProcessSession(key)));
122
+ return;
123
+ }
124
+ const key = sessionKey(dataRoot);
125
+ const current = sessions.get(key);
126
+ sessions.delete(key);
127
+ if (!current) return;
128
+ try {
129
+ const session = current.session ?? (await current.promise.catch(() => null));
130
+ if (session) await session.stop();
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ }
135
+
136
+ export async function resolveApplyBackend(options) {
137
+ if (await hasLiveTray(options.dataRoot)) {
138
+ return { kind: "tray" };
139
+ }
140
+ if (await hasLiveTrayClaim(options.dataRoot)) {
141
+ const deadline = Date.now() + 5_000;
142
+ while (Date.now() < deadline) {
143
+ if (await hasLiveTray(options.dataRoot)) {
144
+ return { kind: "tray" };
145
+ }
146
+ await sleep(100);
147
+ }
148
+ if (await hasLiveTrayClaim(options.dataRoot)) {
149
+ const error = new Error(TRAY_STARTING_MESSAGE);
150
+ error.code = "TRAY_CLAIMED";
151
+ throw error;
152
+ }
153
+ }
154
+ try {
155
+ const session = await ensureInProcessSession(options);
156
+ return { kind: "local", session };
157
+ } catch (error) {
158
+ if (await hasLiveTray(options.dataRoot)) {
159
+ return { kind: "tray" };
160
+ }
161
+ throw error;
162
+ }
163
+ }