diff --git a/apps/tray/session-host.mjs b/apps/tray/session-host.mjs index e7bb49b..3705535 100644 --- a/apps/tray/session-host.mjs +++ b/apps/tray/session-host.mjs @@ -1,559 +1,629 @@ -#!/usr/bin/env node -/** - * Long-lived loopback control plane for the Windows tray. - * - * Listens on 127.0.0.1 only. Every request needs: - * Authorization: Bearer - * where the bearer token is inherited through BEAUTICODE_CONTROL_TOKEN. - * - * Protocol (JSON): - * GET /health - * GET /status - * POST /apply/image { "imagePath": "..." } - * POST /apply/video { "videoPath": "...", "imagePath"?: "...", "startAt"?: number } - * POST /apply/clear {} - * POST /reapply {} // republish active background into live sessions - * POST /theme/save { "name": "..." } // keep current image/video - * GET /theme/list - * POST /theme/use { "id": "..." } - * POST /theme/delete { "id": "..." } - * POST /mode/fish { "enabled": true|false } // 摸鱼 — attribute only - * POST /mode/muted { "muted": true|false } // video sound; default muted - * POST /discover {} - * POST /shutdown {} - */ -import fs from "node:fs"; -import http from "node:http"; -import process from "node:process"; -import path from "node:path"; -import crypto from "node:crypto"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { - isPidAlive, - removeDshControlFile, - removeSessionHostFile, - writeDshControlFile, - writeSessionHostFile, -} from "../../integrations/deepseek-harness/control-client.mjs"; - -if (Number(process.versions.node.split(".", 1)[0]) < 22) { - console.error("session-host 需要 Node.js 22 或更高版本。"); - process.exit(1); -} - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(here, "../.."); - -function argValue(name) { - const idx = process.argv.indexOf(name); - if (idx === -1) return null; - const value = process.argv[idx + 1] ?? null; - return value && !value.startsWith("--") ? value : null; -} - -const hostKind = argValue("--host") ?? "codex"; -if (hostKind !== "codex" && hostKind !== "dsh") { - console.error("--host 必须是 codex 或 dsh。"); - process.exit(1); -} -const adapterFolder = hostKind === "dsh" ? "adapter-dsh" : "adapter-codex"; -const adapterEntry = pathToFileURL( - path.resolve(repoRoot, `packages/${adapterFolder}/dist/index.js`), -).href; - -const token = process.env.BEAUTICODE_CONTROL_TOKEN; -delete process.env.BEAUTICODE_CONTROL_TOKEN; -const portArg = argValue("--port"); -const dshUrl = argValue("--dsh-url") ?? "http://127.0.0.1:3080"; -const dataRoot = argValue("--data-root"); -const parentPidArg = argValue("--parent-pid"); -let parentPid = null; -if (parentPidArg != null) { - parentPid = Number(parentPidArg); - if (!Number.isInteger(parentPid) || parentPid < 1) { - console.error("--parent-pid 必须是正整数。"); - process.exit(1); - } - if (!isPidAlive(parentPid)) { - console.error("session-host:父进程已退出。"); - process.exit(1); - } -} -const verifyMs = Number(argValue("--verify-ms") ?? "30000"); -if (!Number.isFinite(verifyMs) || verifyMs < 0 || verifyMs > 300_000) { - console.error("--verify-ms 必须在 0 到 300000 之间。"); - process.exit(1); -} - -if (!token || token.length < 24) { - console.error("session-host 需要长度至少为 24 个字符的随机控制令牌。"); - process.exit(1); -} - -const adapter = await import(adapterEntry); -const toChineseErrorMessage = adapter.toChineseErrorMessage; - -const sessionOpts = { - verifyDeadlineMs: verifyMs, - autoDiscover: true, - // Tray must show immediately; CDP connect + first publish happen in background. - deferHostConnect: true, - onError: (err) => { - console.error(`[session] ${toChineseErrorMessage(err)}`); - }, - onStatus: (msg) => { - console.error(`[session] ${msg}`); - }, -}; -if (hostKind === "dsh") { - sessionOpts.baseUrl = dshUrl; - sessionOpts.honorTrayHandoff = false; -} -if (portArg) { - const p = Number(portArg); - if (!Number.isInteger(p) || p < 1 || p > 65535) { - console.error("--port 必须是 1–65535 之间的整数。"); - process.exit(1); - } - sessionOpts.port = p; - sessionOpts.autoDiscover = false; -} -if (dataRoot) sessionOpts.dataRoot = path.resolve(dataRoot); -const bundledGalleryHi = path.join( - repoRoot, - "assets", - "themes", - "internal-beyond", - "bg-canvas-4k.png", -); -const bundledGalleryLo = path.join( - repoRoot, - "assets", - "themes", - "internal-beyond", - "bg-canvas.png", -); -if (fs.existsSync(bundledGalleryHi)) { - sessionOpts.bundledGalleryImagePath = bundledGalleryHi; -} else if (fs.existsSync(bundledGalleryLo)) { - sessionOpts.bundledGalleryImagePath = bundledGalleryLo; -} - -const SessionClass = hostKind === "dsh" ? adapter.DshSession : adapter.BeautiSession; -const session = new SessionClass(sessionOpts); -let shuttingDown = false; - -function readBody(req, maxBytes = 64 * 1024) { - return new Promise((resolve, reject) => { - const chunks = []; - let size = 0; - req.on("data", (c) => { - size += c.length; - if (size > maxBytes) { - const error = new Error("request body too large"); - error.statusCode = 413; - reject(error); - req.removeAllListeners("data"); - req.resume(); - return; - } - chunks.push(c); - }); - req.on("end", () => { - if (chunks.length === 0) { - resolve({}); - return; - } - try { - const parsed = JSON.parse( - Buffer.concat(chunks).toString("utf8") || "{}", - ); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - const error = new Error("request body must be a JSON object"); - error.statusCode = 400; - reject(error); - return; - } - resolve(parsed); - } catch (err) { - if (err && typeof err === "object" && !err.statusCode) { - err.statusCode = 400; - } - reject(err); - } - }); - req.on("error", reject); - }); -} - -function publicTheme(theme) { - return { - id: theme.id, - name: theme.name, - type: theme.type, - savedAt: theme.savedAt, - ...(theme.bundled ? { bundled: true } : {}), - ...(typeof theme.videoPositionSec === "number" - ? { videoPositionSec: theme.videoPositionSec } - : {}), - }; -} - -function send(res, status, obj) { - if (res.destroyed || res.writableEnded) return; - const body = JSON.stringify( - obj && typeof obj.error === "string" - ? { ...obj, error: toChineseErrorMessage(obj.error) } - : obj, - ); - res.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - "Content-Length": Buffer.byteLength(body), - }); - res.end(body); -} - -function unauthorized(res) { - send(res, 401, { ok: false, error: "请求未授权。" }); -} - -function checkAuth(req) { - const h = req.headers.authorization; - if (typeof h !== "string") return false; - const m = /^Bearer\s+(.+)$/i.exec(h.trim()); - if (!m) return false; - const a = Buffer.from(m[1]); - const b = Buffer.from(token); - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(a, b); -} - -const server = http.createServer( - { maxHeaderSize: 16 * 1024, requestTimeout: 180_000 }, - async (req, res) => { - try { - if (!checkAuth(req)) { - unauthorized(res); - return; - } - if (shuttingDown) { - send(res, 503, { ok: false, error: "服务正在关闭。" }); - return; - } - const url = req.url?.split("?")[0] ?? ""; - if (req.method === "GET" && url === "/health") { - send(res, 200, { - ok: true, - host: session.descriptor, - port: session.cdpPort, - open: session.isOpen, - busy: session.isBusy, - hostReady: session.isHostReady, - }); - return; - } - if (req.method === "GET" && url === "/status") { - const st = await session.status(); - send(res, 200, { - ok: true, - hostReady: session.isHostReady, - ...st, - }); - return; - } - if (req.method === "GET" && url === "/guidance") { - send(res, 200, { - ok: true, - guidance: - hostKind === "dsh" - ? { - title: "连接 DeepSeek Harness", - steps: [ - "自己运行 dsh web(需已加载 beautiCode 插件)。", - "在浏览器中打开 DSH Web 页面。", - "回到 beautiCode 托盘选择图片。", - ], - } - : adapter.getCodexLaunchGuidance(), - }); - return; - } - if (req.method === "POST" && url === "/discover") { - if (hostKind === "dsh") { - const status = await session.status(); - send(res, 200, { - ok: status.sessions > 0, - endpoints: status.sessions > 0 - ? [{ port: status.port, browser: "DeepSeek Harness", primaryPages: status.sessions, source: "bridge" }] - : [], - }); - return; - } - const endpoints = await adapter.discoverCdpEndpoints({ requirePages: true }); - send(res, 200, { - ok: endpoints.length > 0, - endpoints: endpoints.map((e) => ({ - port: e.port, - browser: e.browser, - primaryPages: e.primaryPages, - source: e.source, - })), - }); - return; - } - if (req.method === "POST" && url === "/apply/image") { - const body = await readBody(req); - if (typeof body.imagePath !== "string" || !body.imagePath) { - send(res, 400, { ok: false, error: "必须提供 imagePath。" }); - return; - } - const imageInput = { - type: "image", - imagePath: path.resolve(body.imagePath), - }; - if (body.effects && typeof body.effects === "object") { - imageInput.effects = body.effects; - } - const result = await session.apply(imageInput); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/apply/video") { - const body = await readBody(req); - if (typeof body.videoPath !== "string" || !body.videoPath) { - send(res, 400, { - ok: false, - error: "必须提供 videoPath(MP4);imagePath 可选,用作海报图片。", - }); - return; - } - const videoInput = { - type: "video", - videoPath: path.resolve(body.videoPath), - }; - if (typeof body.imagePath === "string" && body.imagePath) { - videoInput.imagePath = path.resolve(body.imagePath); - } - if (body.startAt != null) { - const startAt = Number(body.startAt); - if (!Number.isFinite(startAt) || startAt < 0) { - send(res, 400, { ok: false, error: "startAt 必须是非负数字(秒)。" }); - return; - } - videoInput.startAt = startAt; - } - const result = await session.apply(videoInput); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/apply/clear") { - const result = await session.apply({ type: "clear" }); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/reapply") { - const result = await session.reapply(); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/theme/save") { - const body = await readBody(req); - if (typeof body.name !== "string" || !body.name.trim()) { - send(res, 400, { ok: false, error: "必须提供主题名称。" }); - return; - } - try { - const theme = await session.saveCurrentTheme(body.name); - send(res, 200, { ok: true, theme: publicTheme(theme) }); - } catch (err) { - send(res, 422, { - ok: false, - error: toChineseErrorMessage(err), - }); - } - return; - } - if (req.method === "GET" && url === "/theme/list") { - const themes = await session.listSavedThemes(); - send(res, 200, { ok: true, themes: themes.map(publicTheme) }); - return; - } - if (req.method === "POST" && url === "/theme/use") { - const body = await readBody(req); - if (typeof body.id !== "string" || !body.id.trim()) { - send(res, 400, { ok: false, error: "必须提供主题 ID。" }); - return; - } - const result = await session.useSavedTheme(body.id.trim()); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/theme/delete") { - const body = await readBody(req); - if (typeof body.id !== "string" || !body.id.trim()) { - send(res, 400, { ok: false, error: "必须提供主题 ID。" }); - return; - } - try { - const deleted = await session.deleteSavedTheme(body.id.trim()); - send(res, deleted ? 200 : 404, { - ok: deleted, - deleted, - ...(deleted ? {} : { error: "未找到已保存的主题。" }), - }); - } catch (err) { - send(res, 422, { - ok: false, - deleted: false, - error: toChineseErrorMessage(err), - }); - } - return; - } - if (req.method === "POST" && url === "/mode/fish") { - const body = await readBody(req); - if (typeof body.enabled !== "boolean") { - send(res, 400, { ok: false, error: "enabled 必须是布尔值。" }); - return; - } - const result = await session.setFishMode(body.enabled); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/mode/muted") { - const body = await readBody(req); - if (typeof body.muted !== "boolean") { - send(res, 400, { ok: false, error: "muted 必须是布尔值。" }); - return; - } - const result = await session.setMuted(body.muted); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/mode/tone") { - const body = await readBody(req); - if (!["dark", "light", "auto"].includes(body.tone)) { - send(res, 400, { ok: false, error: "tone 必须是 dark、light 或 auto。" }); - return; - } - const result = await session.setBackgroundTone(body.tone); - send(res, result.ok ? 200 : 422, result); - return; - } - if (req.method === "POST" && url === "/shutdown") { - send(res, 200, { ok: true }); - setImmediate(() => void shutdown(0)); - return; - } - send(res, 404, { ok: false, error: "未找到请求的资源。" }); - } catch (err) { - const status = - err && typeof err === "object" && Number.isInteger(err.statusCode) - ? err.statusCode - : 500; - send(res, status, { - ok: false, - error: toChineseErrorMessage(err), - }); - } - }, -); -server.headersTimeout = 10_000; -server.requestTimeout = 180_000; -server.keepAliveTimeout = 5_000; -server.maxRequestsPerSocket = 100; - -async function shutdown(code) { - if (shuttingDown) return; - shuttingDown = true; - if (hostKind === "dsh") { - try { - await removeDshControlFile({ dataRoot: session.dataRoot, pid: process.pid }); - } catch { - /* ignore */ - } - } - try { - await removeSessionHostFile({ dataRoot: session.dataRoot, pid: process.pid }); - } catch { - /* ignore */ - } - const serverClosed = new Promise((resolve) => server.close(() => resolve())); - server.closeIdleConnections?.(); - try { - await session.stop(); - } catch { - /* ignore */ - } - server.closeAllConnections?.(); - await serverClosed; - process.exitCode = code; - setTimeout(() => process.exit(code), 1_000).unref?.(); -} - -try { - await session.start(); -} catch (err) { - console.error( - toChineseErrorMessage(err), - ); - console.error("session-host:beautiCode 会话启动失败,已安全退出。"); - process.exit(1); -} - -await new Promise((resolve, reject) => { - server.listen(0, "127.0.0.1", () => resolve()); - server.on("error", reject); -}); -process.on("SIGINT", () => void shutdown(0)); -process.on("SIGTERM", () => void shutdown(0)); -if (parentPid != null) { - const parentWatch = setInterval(() => { - if (!isPidAlive(parentPid)) void shutdown(0); - }, 500); - parentWatch.unref?.(); -} -const addr = server.address(); -const listenPort = typeof addr === "object" && addr ? addr.port : 0; -const controlUrl = `http://127.0.0.1:${listenPort}`; -try { - await writeSessionHostFile({ - dataRoot: session.dataRoot, - host: hostKind, - url: controlUrl, - token, - pid: process.pid, - }); -} catch (error) { - console.error( - `session-host:无法发布控制面文件:${ - error instanceof Error ? error.message : String(error) - }`, - ); -} -if (hostKind === "dsh") { - try { - await writeDshControlFile({ - dataRoot: session.dataRoot, - url: controlUrl, - token, - pid: process.pid, - }); - } catch (error) { - console.error( - `session-host:无法发布 DSH 控制面,对话导入和斜杠命令将不可用:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } -} -// Machine-readable ready line for the tray launcher (stdout). -console.log( - JSON.stringify({ - ready: true, - host: hostKind, - controlPort: listenPort, - cdpPort: session.cdpPort, - }), -); +#!/usr/bin/env node +/** + * Long-lived loopback control plane for the Windows tray. + * + * Listens on 127.0.0.1 only. Every request needs: + * Authorization: Bearer + * where the bearer token is inherited through BEAUTICODE_CONTROL_TOKEN. + * + * Protocol (JSON): + * GET /health + * GET /status + * POST /apply/image { "imagePath": "...", "source"?: "managed"|"local" } + * POST /apply/video { "videoPath": "...", "imagePath"?: "...", "source"?: "managed"|"local", "startAt"?: number } + * POST /apply/clear {} + * POST /reapply {} // republish active background into live sessions + * POST /theme/apply { "name": "...", "input": ApplyInput } + * POST /theme/save { "name": "..." } // keep current image/video + * GET /theme/list + * POST /theme/use { "id": "..." } + * POST /theme/delete { "id": "..." } + * POST /mode/fish { "enabled": true|false } // 摸鱼 — attribute only + * POST /mode/muted { "muted": true|false } // video sound; default muted + * POST /discover {} + * POST /shutdown {} + */ +import fs from "node:fs"; +import http from "node:http"; +import process from "node:process"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + isPidAlive, + removeDshControlFile, + removeSessionHostFile, + writeDshControlFile, + writeSessionHostFile, +} from "../../integrations/deepseek-harness/control-client.mjs"; + +if (Number(process.versions.node.split(".", 1)[0]) < 22) { + console.error("session-host 需要 Node.js 22 或更高版本。"); + process.exit(1); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../.."); + +function argValue(name) { + const idx = process.argv.indexOf(name); + if (idx === -1) return null; + const value = process.argv[idx + 1] ?? null; + return value && !value.startsWith("--") ? value : null; +} + +function parseImportMode(value) { + if (value == null) return undefined; + if (value === "managed" || value === "local") return value; + throw new Error("source 必须是 managed 或 local。"); +} + +function parseThemeApplyInput(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("input 必须是图片或视频导入参数。"); + } + if (value.type === "image") { + if (typeof value.imagePath !== "string" || !value.imagePath) { + throw new Error("图片主题必须提供 imagePath。"); + } + const input = { + type: "image", + imagePath: path.resolve(value.imagePath), + source: parseImportMode(value.source), + }; + if (value.effects && typeof value.effects === "object") { + input.effects = value.effects; + } + return input; + } + if (value.type === "video") { + if (typeof value.videoPath !== "string" || !value.videoPath) { + throw new Error("视频主题必须提供 videoPath。"); + } + const input = { + type: "video", + videoPath: path.resolve(value.videoPath), + source: parseImportMode(value.source), + }; + if (typeof value.imagePath === "string" && value.imagePath) { + input.imagePath = path.resolve(value.imagePath); + } + if (value.startAt != null) { + const startAt = Number(value.startAt); + if (!Number.isFinite(startAt) || startAt < 0) { + throw new Error("startAt 必须是非负数字(秒)。"); + } + input.startAt = startAt; + } + return input; + } + throw new Error("input.type 必须是 image 或 video。"); +} + +const hostKind = argValue("--host") ?? "codex"; +if (hostKind !== "codex" && hostKind !== "dsh") { + console.error("--host 必须是 codex 或 dsh。"); + process.exit(1); +} +const adapterFolder = hostKind === "dsh" ? "adapter-dsh" : "adapter-codex"; +const adapterEntry = pathToFileURL( + path.resolve(repoRoot, `packages/${adapterFolder}/dist/index.js`), +).href; + +const token = process.env.BEAUTICODE_CONTROL_TOKEN; +delete process.env.BEAUTICODE_CONTROL_TOKEN; +const portArg = argValue("--port"); +const dshUrl = argValue("--dsh-url") ?? "http://127.0.0.1:3080"; +const dataRoot = argValue("--data-root"); +const parentPidArg = argValue("--parent-pid"); +let parentPid = null; +if (parentPidArg != null) { + parentPid = Number(parentPidArg); + if (!Number.isInteger(parentPid) || parentPid < 1) { + console.error("--parent-pid 必须是正整数。"); + process.exit(1); + } + if (!isPidAlive(parentPid)) { + console.error("session-host:父进程已退出。"); + process.exit(1); + } +} +const verifyMs = Number(argValue("--verify-ms") ?? "30000"); +if (!Number.isFinite(verifyMs) || verifyMs < 0 || verifyMs > 300_000) { + console.error("--verify-ms 必须在 0 到 300000 之间。"); + process.exit(1); +} + +if (!token || token.length < 24) { + console.error("session-host 需要长度至少为 24 个字符的随机控制令牌。"); + process.exit(1); +} + +const adapter = await import(adapterEntry); +const toChineseErrorMessage = adapter.toChineseErrorMessage; + +const sessionOpts = { + verifyDeadlineMs: verifyMs, + autoDiscover: true, + // Tray must show immediately; CDP connect + first publish happen in background. + deferHostConnect: true, + onError: (err) => { + console.error(`[session] ${toChineseErrorMessage(err)}`); + }, + onStatus: (msg) => { + console.error(`[session] ${msg}`); + }, +}; +if (hostKind === "dsh") { + sessionOpts.baseUrl = dshUrl; + sessionOpts.honorTrayHandoff = false; +} +if (portArg) { + const p = Number(portArg); + if (!Number.isInteger(p) || p < 1 || p > 65535) { + console.error("--port 必须是 1–65535 之间的整数。"); + process.exit(1); + } + sessionOpts.port = p; + sessionOpts.autoDiscover = false; +} +if (dataRoot) sessionOpts.dataRoot = path.resolve(dataRoot); +const bundledGalleryHi = path.join( + repoRoot, + "assets", + "themes", + "internal-beyond", + "bg-canvas-4k.png", +); +const bundledGalleryLo = path.join( + repoRoot, + "assets", + "themes", + "internal-beyond", + "bg-canvas.png", +); +if (fs.existsSync(bundledGalleryHi)) { + sessionOpts.bundledGalleryImagePath = bundledGalleryHi; +} else if (fs.existsSync(bundledGalleryLo)) { + sessionOpts.bundledGalleryImagePath = bundledGalleryLo; +} + +const SessionClass = hostKind === "dsh" ? adapter.DshSession : adapter.BeautiSession; +const session = new SessionClass(sessionOpts); +let shuttingDown = false; + +function readBody(req, maxBytes = 64 * 1024) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (c) => { + size += c.length; + if (size > maxBytes) { + const error = new Error("request body too large"); + error.statusCode = 413; + reject(error); + req.removeAllListeners("data"); + req.resume(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + if (chunks.length === 0) { + resolve({}); + return; + } + try { + const parsed = JSON.parse( + Buffer.concat(chunks).toString("utf8") || "{}", + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const error = new Error("request body must be a JSON object"); + error.statusCode = 400; + reject(error); + return; + } + resolve(parsed); + } catch (err) { + if (err && typeof err === "object" && !err.statusCode) { + err.statusCode = 400; + } + reject(err); + } + }); + req.on("error", reject); + }); +} + +function publicTheme(theme) { + return { + id: theme.id, + name: theme.name, + type: theme.type, + savedAt: theme.savedAt, + ...(theme.bundled ? { bundled: true } : {}), + ...(typeof theme.videoPositionSec === "number" + ? { videoPositionSec: theme.videoPositionSec } + : {}), + }; +} + +function send(res, status, obj) { + if (res.destroyed || res.writableEnded) return; + const body = JSON.stringify( + obj && typeof obj.error === "string" + ? { ...obj, error: toChineseErrorMessage(obj.error) } + : obj, + ); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +function unauthorized(res) { + send(res, 401, { ok: false, error: "请求未授权。" }); +} + +function checkAuth(req) { + const h = req.headers.authorization; + if (typeof h !== "string") return false; + const m = /^Bearer\s+(.+)$/i.exec(h.trim()); + if (!m) return false; + const a = Buffer.from(m[1]); + const b = Buffer.from(token); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +const server = http.createServer( + { maxHeaderSize: 16 * 1024, requestTimeout: 180_000 }, + async (req, res) => { + try { + if (!checkAuth(req)) { + unauthorized(res); + return; + } + if (shuttingDown) { + send(res, 503, { ok: false, error: "服务正在关闭。" }); + return; + } + const url = req.url?.split("?")[0] ?? ""; + if (req.method === "GET" && url === "/health") { + send(res, 200, { + ok: true, + host: session.descriptor, + port: session.cdpPort, + open: session.isOpen, + busy: session.isBusy, + hostReady: session.isHostReady, + }); + return; + } + if (req.method === "GET" && url === "/status") { + const st = await session.status(); + send(res, 200, { + ok: true, + hostReady: session.isHostReady, + ...st, + }); + return; + } + if (req.method === "GET" && url === "/guidance") { + send(res, 200, { + ok: true, + guidance: + hostKind === "dsh" + ? { + title: "连接 DeepSeek Harness", + steps: [ + "自己运行 dsh web(需已加载 beautiCode 插件)。", + "在浏览器中打开 DSH Web 页面。", + "回到 beautiCode 托盘选择图片。", + ], + } + : adapter.getCodexLaunchGuidance(), + }); + return; + } + if (req.method === "POST" && url === "/discover") { + if (hostKind === "dsh") { + const status = await session.status(); + send(res, 200, { + ok: status.sessions > 0, + endpoints: status.sessions > 0 + ? [{ port: status.port, browser: "DeepSeek Harness", primaryPages: status.sessions, source: "bridge" }] + : [], + }); + return; + } + const endpoints = await adapter.discoverCdpEndpoints({ requirePages: true }); + send(res, 200, { + ok: endpoints.length > 0, + endpoints: endpoints.map((e) => ({ + port: e.port, + browser: e.browser, + primaryPages: e.primaryPages, + source: e.source, + })), + }); + return; + } + if (req.method === "POST" && url === "/apply/image") { + const body = await readBody(req); + if (typeof body.imagePath !== "string" || !body.imagePath) { + send(res, 400, { ok: false, error: "必须提供 imagePath。" }); + return; + } + const imageInput = { + type: "image", + imagePath: path.resolve(body.imagePath), + }; + imageInput.source = parseImportMode(body.source); + if (body.effects && typeof body.effects === "object") { + imageInput.effects = body.effects; + } + const result = await session.apply(imageInput); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/apply/video") { + const body = await readBody(req); + if (typeof body.videoPath !== "string" || !body.videoPath) { + send(res, 400, { + ok: false, + error: "必须提供 videoPath(MP4);imagePath 可选,用作海报图片。", + }); + return; + } + const videoInput = { + type: "video", + videoPath: path.resolve(body.videoPath), + }; + videoInput.source = parseImportMode(body.source); + if (typeof body.imagePath === "string" && body.imagePath) { + videoInput.imagePath = path.resolve(body.imagePath); + } + if (body.startAt != null) { + const startAt = Number(body.startAt); + if (!Number.isFinite(startAt) || startAt < 0) { + send(res, 400, { ok: false, error: "startAt 必须是非负数字(秒)。" }); + return; + } + videoInput.startAt = startAt; + } + const result = await session.apply(videoInput); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/apply/clear") { + const result = await session.apply({ type: "clear" }); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/reapply") { + const result = await session.reapply(); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/theme/apply") { + const body = await readBody(req); + if (typeof body.name !== "string" || !body.name.trim()) { + send(res, 400, { ok: false, error: "必须提供主题名称。" }); + return; + } + if (typeof session.applyAndSaveTheme !== "function") { + send(res, 501, { ok: false, error: "当前会话不支持应用并保存主题。" }); + return; + } + const input = parseThemeApplyInput(body.input); + const result = await session.applyAndSaveTheme(input, body.name.trim()); + send( + res, + result.ok ? 200 : 422, + result.ok ? { ...result, theme: publicTheme(result.theme) } : result, + ); + return; + } + if (req.method === "POST" && url === "/theme/save") { + const body = await readBody(req); + if (typeof body.name !== "string" || !body.name.trim()) { + send(res, 400, { ok: false, error: "必须提供主题名称。" }); + return; + } + try { + const theme = await session.saveCurrentTheme(body.name); + send(res, 200, { ok: true, theme: publicTheme(theme) }); + } catch (err) { + send(res, 422, { + ok: false, + error: toChineseErrorMessage(err), + }); + } + return; + } + if (req.method === "GET" && url === "/theme/list") { + const themes = await session.listSavedThemes(); + send(res, 200, { ok: true, themes: themes.map(publicTheme) }); + return; + } + if (req.method === "POST" && url === "/theme/use") { + const body = await readBody(req); + if (typeof body.id !== "string" || !body.id.trim()) { + send(res, 400, { ok: false, error: "必须提供主题 ID。" }); + return; + } + const result = await session.useSavedTheme(body.id.trim()); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/theme/delete") { + const body = await readBody(req); + if (typeof body.id !== "string" || !body.id.trim()) { + send(res, 400, { ok: false, error: "必须提供主题 ID。" }); + return; + } + try { + const deleted = await session.deleteSavedTheme(body.id.trim()); + send(res, deleted ? 200 : 404, { + ok: deleted, + deleted, + ...(deleted ? {} : { error: "未找到已保存的主题。" }), + }); + } catch (err) { + send(res, 422, { + ok: false, + deleted: false, + error: toChineseErrorMessage(err), + }); + } + return; + } + if (req.method === "POST" && url === "/mode/fish") { + const body = await readBody(req); + if (typeof body.enabled !== "boolean") { + send(res, 400, { ok: false, error: "enabled 必须是布尔值。" }); + return; + } + const result = await session.setFishMode(body.enabled); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/mode/muted") { + const body = await readBody(req); + if (typeof body.muted !== "boolean") { + send(res, 400, { ok: false, error: "muted 必须是布尔值。" }); + return; + } + const result = await session.setMuted(body.muted); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/mode/tone") { + const body = await readBody(req); + if (!["dark", "light", "auto"].includes(body.tone)) { + send(res, 400, { ok: false, error: "tone 必须是 dark、light 或 auto。" }); + return; + } + const result = await session.setBackgroundTone(body.tone); + send(res, result.ok ? 200 : 422, result); + return; + } + if (req.method === "POST" && url === "/shutdown") { + send(res, 200, { ok: true }); + setImmediate(() => void shutdown(0)); + return; + } + send(res, 404, { ok: false, error: "未找到请求的资源。" }); + } catch (err) { + const status = + err && typeof err === "object" && Number.isInteger(err.statusCode) + ? err.statusCode + : 500; + send(res, status, { + ok: false, + error: toChineseErrorMessage(err), + }); + } + }, +); +server.headersTimeout = 10_000; +server.requestTimeout = 180_000; +server.keepAliveTimeout = 5_000; +server.maxRequestsPerSocket = 100; + +async function shutdown(code) { + if (shuttingDown) return; + shuttingDown = true; + if (hostKind === "dsh") { + try { + await removeDshControlFile({ dataRoot: session.dataRoot, pid: process.pid }); + } catch { + /* ignore */ + } + } + try { + await removeSessionHostFile({ dataRoot: session.dataRoot, pid: process.pid }); + } catch { + /* ignore */ + } + const serverClosed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections?.(); + try { + await session.stop(); + } catch { + /* ignore */ + } + server.closeAllConnections?.(); + await serverClosed; + process.exitCode = code; + setTimeout(() => process.exit(code), 1_000).unref?.(); +} + +try { + await session.start(); +} catch (err) { + console.error( + toChineseErrorMessage(err), + ); + console.error("session-host:beautiCode 会话启动失败,已安全退出。"); + process.exit(1); +} + +await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.on("error", reject); +}); +process.on("SIGINT", () => void shutdown(0)); +process.on("SIGTERM", () => void shutdown(0)); +if (parentPid != null) { + const parentWatch = setInterval(() => { + if (!isPidAlive(parentPid)) void shutdown(0); + }, 500); + parentWatch.unref?.(); +} +const addr = server.address(); +const listenPort = typeof addr === "object" && addr ? addr.port : 0; +const controlUrl = `http://127.0.0.1:${listenPort}`; +try { + await writeSessionHostFile({ + dataRoot: session.dataRoot, + host: hostKind, + url: controlUrl, + token, + pid: process.pid, + }); +} catch (error) { + console.error( + `session-host:无法发布控制面文件:${ + error instanceof Error ? error.message : String(error) + }`, + ); +} +if (hostKind === "dsh") { + try { + await writeDshControlFile({ + dataRoot: session.dataRoot, + url: controlUrl, + token, + pid: process.pid, + }); + } catch (error) { + console.error( + `session-host:无法发布 DSH 控制面,对话导入和斜杠命令将不可用:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} +// Machine-readable ready line for the tray launcher (stdout). +console.log( + JSON.stringify({ + ready: true, + host: hostKind, + controlPort: listenPort, + cdpPort: session.cdpPort, + }), +); diff --git a/design-demos/dsh-background-bar/design-spec.md b/design-demos/dsh-background-bar/design-spec.md new file mode 100644 index 0000000..a3a1526 --- /dev/null +++ b/design-demos/dsh-background-bar/design-spec.md @@ -0,0 +1,9 @@ +# DSH 背景栏视觉方向说明 + +本次不是重做 DSH,也不是把背景功能扩张成独立的素材管理器。目标是让侧边栏里的「背景」入口更像一个稳定、可信、长期使用的本地工具:用户能在几秒内完成图片导入、视频导入、声音控制、清除背景和已保存主题切换;正在处理时知道系统在做什么;发生错误或超时时按钮一定恢复;同时不把绝对路径、技术协议或多余统计暴露给普通用户。背景栏仍然依附 DSH 左侧栏,不能抢过会话、模型和输入区的主层级。 + +受众是主要在 Windows 本机使用 DSH 的用户。观看距离约为普通笔记本 60–90 厘米,面板宽度应控制在 224–272 像素,正文不低于 13 像素,辅助标签不低于 11 像素。视觉温度需要冷静、可靠、轻微有人味,避免常见 AI 产品的紫蓝渐变、发光描边、Bento 卡片堆叠、每个动作都配一个装饰图标,以及过度圆润的胶囊按钮。颜色从真实「画窗」背景里的雨蓝、雾灰和 DSH 现有深色侧栏采样,收敛为一组中性灰阶加一个低饱和蓝;错误只在发生时使用克制的红色。字体沿用 DSH 系统字体,不额外下载字体。 + +内容结构固定为四层:第一层是当前背景和来源状态;第二层是最常用的图片、视频导入;第三层是声音、清除、皮肤中心等次级操作;第四层是已保存主题。主题列表只使用现有名称、媒体类型、来源类型和选中状态,不新增缩略图接口,不把本地路径送到网页。忙碌状态必须局部化:当前操作显示进度文本,其余按钮暂时不可用,但面板仍可关闭;45 秒客户端上限后恢复交互并说明原背景保持不变。原生文件选择器是用户控制的系统窗口,不套用该操作超时。 + +三个方向共享以上功能,但构图互异。方向 A 用包豪斯式规则线和编号建立明确秩序,强调“工具盒”;方向 B 最大程度融入 DSH 本体,把状态和动作做成低噪声的原生侧栏;方向 C 借鉴纸质媒体清单,把导入动作和已保存主题放在一条连续的纵向轨道里,强调主题切换效率。三版都不依赖新增后端能力,选定后只改 `console.js` 的结构和样式,不动 local 导入、事务或媒体服务协议。 diff --git a/design-demos/dsh-background-bar/direction-approved.md b/design-demos/dsh-background-bar/direction-approved.md new file mode 100644 index 0000000..dc8f820 --- /dev/null +++ b/design-demos/dsh-background-bar/direction-approved.md @@ -0,0 +1,6 @@ +# DSH 背景栏方向确认 + +- 已评审:A「网格工具盒」、B「原生侧栏」、C「媒体清单」 +- 最终设计:`direction-c-media-ledger.html`、`direction-c-media-ledger.png` +- 用户选择原话:`C吧` +- 结论:正式背景栏采用 C「媒体清单」方向;保持现有 DSH 侧栏位置、local 零复制导入、主题切换和超时恢复语义。 diff --git a/design-demos/dsh-background-bar/direction-c-media-ledger.html b/design-demos/dsh-background-bar/direction-c-media-ledger.html new file mode 100644 index 0000000..6843793 --- /dev/null +++ b/design-demos/dsh-background-bar/direction-c-media-ledger.html @@ -0,0 +1,4 @@ + +方向 C · 媒体清单
探索未至之境
diff --git a/design-demos/dsh-background-bar/direction-c-media-ledger.png b/design-demos/dsh-background-bar/direction-c-media-ledger.png new file mode 100644 index 0000000..997755b Binary files /dev/null and b/design-demos/dsh-background-bar/direction-c-media-ledger.png differ diff --git a/integrations/deepseek-harness/README.zh-CN.md b/integrations/deepseek-harness/README.zh-CN.md index 6d33156..8364492 100644 --- a/integrations/deepseek-harness/README.zh-CN.md +++ b/integrations/deepseek-harness/README.zh-CN.md @@ -1,48 +1,48 @@ -# DeepSeek Harness 桥接插件 - -该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。 - -装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换已保存主题。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。 - -插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。 - -beautiCode **不启动** DeepSeek Harness。请先自己运行 `dsh web`。 - -不需要 fork 仓库。一行安装: - -```sh -npx beauticode-dsh -npx @deepseek-ai/dsh web -``` - -已有 pnpm 时也可以: - -```sh -npx @deepseek-ai/dsh plugin --profile web add beauticode-dsh -npx @deepseek-ai/dsh web -``` - -在仓库根目录一键写入插件(不需要 pnpm): - -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-dsh-plugin.ps1 -npx @deepseek-ai/dsh web -``` - -也可以手动(`dsh plugin add` 需要 pnpm): - -```sh -dsh plugin --profile web add file:%CD%/integrations/deepseek-harness -# 或 -npx @deepseek-ai/dsh plugin --profile web add file:%CD%/integrations/deepseek-harness -npx @deepseek-ai/dsh web -``` - -手动接入时: - -1. 修改 `cordis.patch.example.yml` 中的 `file:///.../index.mjs`。 -2. 确保 DSH 和 beautiCode 使用相同的 `BEAUTICODE_DATA_ROOT`。 -3. 自己运行 `dsh web`(或带 `--patch`)。 -4. 打开页面后用侧栏「背景」,或输入 `/bg <本机绝对路径>`,或直接跟 AI 说把某个视频/图片设为背景。 - -安全边界:DSH Web 必须绑定本机回环地址;控制端点需要随机令牌;媒体 URL 仅允许带令牌的回环 HTTP 地址;浏览器回执只接受同源请求。 +# DeepSeek Harness 桥接插件 + +该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。 + +装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:在 Windows 上点击图片或视频会打开系统文件选择器,选好后在页面内命名,背景立即应用并进入已保存主题;也可以清除、开关声音、切换或删除主题,以及打开「皮肤中心」安装已审核的社区皮肤。本地导入只在 Node 端保存绝对路径,并通过带 Range 支持的回环媒体服务读取原文件,不复制用户选择的图片或整段视频主文件。Windows 强制使用本地引用;原生选择器不可用时会明确报错,不会静默上传或复制媒体。非 Windows 环境保留兼容上传,并明确提示该模式会保存托管副本。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。 + +插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。 + +beautiCode **不启动** DeepSeek Harness。请先自己运行 `dsh web`。 + +不需要 fork 仓库。一行安装: + +```sh +npx beauticode-dsh +npx @deepseek-ai/dsh web +``` + +已有 pnpm 时也可以: + +```sh +npx @deepseek-ai/dsh plugin --profile web add beauticode-dsh +npx @deepseek-ai/dsh web +``` + +在仓库根目录一键写入插件(不需要 pnpm): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-dsh-plugin.ps1 +npx @deepseek-ai/dsh web +``` + +也可以手动(`dsh plugin add` 需要 pnpm): + +```sh +dsh plugin --profile web add file:%CD%/integrations/deepseek-harness +# 或 +npx @deepseek-ai/dsh plugin --profile web add file:%CD%/integrations/deepseek-harness +npx @deepseek-ai/dsh web +``` + +手动接入时: + +1. 修改 `cordis.patch.yml` 中的插件接入项;优先使用上面的安装命令自动完成接线。 +2. 确保 DSH 和 beautiCode 使用相同的 `BEAUTICODE_DATA_ROOT`。 +3. 自己运行 `dsh web`(或带 `--patch`)。 +4. 打开页面后用侧栏「背景」,或输入 `/bg <本机绝对路径>`,或直接跟 AI 说把某个视频/图片设为背景。 + +安全边界:DSH Web 必须绑定本机回环地址;控制端点需要随机令牌;媒体 URL 仅允许带令牌的回环 HTTP 地址;浏览器回执只接受同源请求。 diff --git a/integrations/deepseek-harness/agent.mjs b/integrations/deepseek-harness/agent.mjs index f4b1810..1775879 100644 --- a/integrations/deepseek-harness/agent.mjs +++ b/integrations/deepseek-harness/agent.mjs @@ -1,595 +1,760 @@ -import { - callDshControl, - formatStatusText, - inspectLocalMedia, - matchSavedTheme, - stripPathQuotes, -} from "./control-client.mjs"; -import { resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs"; -import { ATMOSPHERE_PRESETS, effectsForPreset, presetImagePath } from "./presets.mjs"; - -const APPLY_TIMEOUT_MS = 180_000; -const QUICK_TIMEOUT_MS = 15_000; - -const RESULT_SCHEMA = { - type: "object", - additionalProperties: true, - properties: { - ok: { type: "boolean" }, - message: { type: "string" }, - }, - required: ["ok", "message"], -}; - -const BG_USAGE = - "用法:/bg <本机图片或 MP4 绝对路径>。切换主题用 /bg-theme <名称>,清除用 /bg-clear。"; - -function asText(message) { - return [{ type: "text", text: String(message) }]; -} - -function fail(error) { - throw error instanceof Error ? error : new Error(String(error)); -} - -function commandResultFromError(error) { - return { - kind: "error", - text: error instanceof Error ? error.message : String(error), - }; -} - -function normalizeActionOptions(dataRootOrOptions) { - if (typeof dataRootOrOptions === "string") { - return { dataRoot: dataRootOrOptions }; - } - return dataRootOrOptions && typeof dataRootOrOptions === "object" - ? dataRootOrOptions - : {}; -} - -function unwrapApply(result, fallbackMode, message) { - if (!result || result.ok === false) { - fail(result?.error || "操作失败。"); - } - return { - ok: true, - generation: result.generation ?? null, - mode: result.mode ?? fallbackMode, - message, - }; -} - -function presentStatus(status) { - return { - ok: true, - hostReady: status.hostReady === true || status.sessions > 0, - sessions: status.sessions ?? 0, - fish: status.fish === true, - muted: status.muted !== false, - tone: status.tone ?? "dark", - background: status.manifest?.background ?? status.background ?? null, - message: formatStatusText(status), - }; -} - -function presentThemes(themes) { - const list = Array.isArray(themes) ? themes : []; - return { - ok: true, - themes: list, - message: - list.length === 0 - ? "还没有已保存的主题。" - : `已保存主题:${list.map((theme) => theme.name).join("、")}。`, - }; -} - -export function createBeauticodeActions(dataRootOrOptions) { - const options = normalizeActionOptions(dataRootOrOptions); - const dataRoot = options.dataRoot; - const request = (spec) => - callDshControl(dataRoot, { - timeoutMs: spec.timeoutMs ?? APPLY_TIMEOUT_MS, - ...spec, - }); - - async function backend() { - return resolveApplyBackend(options); - } - - return { - async applyImage(imagePath, signal, options) { - const inspected = await inspectLocalMedia(imagePath); - if (!inspected.ok) fail(inspected.error); - if (inspected.kind !== "image") { - fail("beauticode_apply_image 只接受图片文件。"); - } - const effects = effectsForPreset(options?.effects?.preset) || options?.effects || null; - const input = { type: "image", imagePath: inspected.path }; - if (effects) input.effects = effects; - const resolved = await backend(); - if (resolved.kind === "tray") { - const body = { imagePath: inspected.path }; - if (effects) body.effects = effects; - return unwrapApply( - await request({ - method: "POST", - path: "/apply/image", - body, - signal, - }), - "image", - "已将图片设为背景。", - ); - } - return unwrapApply(await resolved.session.apply(input), "image", "已将图片设为背景。"); - }, - - async applyPreset(id, signal) { - const preset = ATMOSPHERE_PRESETS[id]; - const imagePath = presetImagePath(id); - if (!preset || !imagePath) fail("未找到内置主题文件。"); - const result = await this.applyImage(imagePath, signal, { - effects: effectsForPreset(id), - }); - try { - await this.setTone(preset.tone, signal); - } catch { - /* tone is best-effort; the wallpaper still applied */ - } - return { - ...result, - atmosphere: id, - message: `已应用 ${preset.name} 活壁纸。`, - }; - }, - - async setTone(tone, signal) { - const resolved = await backend(); - const result = - resolved.kind === "tray" - ? await request({ - method: "POST", - path: "/mode/tone", - body: { tone }, - signal, - timeoutMs: QUICK_TIMEOUT_MS, - }) - : await resolved.session.setBackgroundTone(tone); - if (result.ok === false) fail(result.error || "无法切换背景色调。"); - return { ok: true, tone: result.tone ?? tone }; - }, - - async applyVideo(input, signal) { - const inspected = await inspectLocalMedia(input.path); - if (!inspected.ok) fail(inspected.error); - if (inspected.kind !== "video") { - fail("beauticode_apply_video 只接受 .mp4 文件。"); - } - const body = { videoPath: inspected.path }; - const localInput = { type: "video", videoPath: inspected.path }; - if (typeof input.poster === "string" && input.poster.trim()) { - const poster = await inspectLocalMedia(input.poster); - if (!poster.ok) fail(poster.error); - if (poster.kind !== "image") fail("poster 必须是图片文件。"); - body.imagePath = poster.path; - localInput.imagePath = poster.path; - } - if (input.startAt != null && input.startAt !== "") { - const startAt = Number(input.startAt); - if (!Number.isFinite(startAt) || startAt < 0) { - fail("startAt 必须是非负数字(秒)。"); - } - body.startAt = startAt; - localInput.startAt = startAt; - } - const resolved = await backend(); - if (resolved.kind === "tray") { - return unwrapApply( - await request({ - method: "POST", - path: "/apply/video", - body, - signal, - }), - "video", - "已将视频设为背景。", - ); - } - return unwrapApply( - await resolved.session.apply(localInput), - "video", - "已将视频设为背景。", - ); - }, - - async clear(signal) { - const resolved = await backend(); - if (resolved.kind === "tray") { - return unwrapApply( - await request({ - method: "POST", - path: "/apply/clear", - body: {}, - signal, - }), - "clear", - "已清除背景。", - ); - } - return unwrapApply(await resolved.session.apply({ type: "clear" }), "clear", "已清除背景。"); - }, - - async status(signal) { - const resolved = await backend(); - if (resolved.kind === "tray") { - return presentStatus( - await request({ - method: "GET", - path: "/status", - signal, - timeoutMs: QUICK_TIMEOUT_MS, - }), - ); - } - const status = await resolved.session.status(); - return presentStatus({ - ...status, - hostReady: resolved.session.isHostReady, - }); - }, - - async listThemes(signal) { - const resolved = await backend(); - if (resolved.kind === "tray") { - const result = await request({ - method: "GET", - path: "/theme/list", - signal, - timeoutMs: QUICK_TIMEOUT_MS, - }); - return presentThemes(result.themes); - } - return presentThemes(await resolved.session.listSavedThemes()); - }, - - async useTheme(query, signal) { - const listed = await this.listThemes(signal); - const matched = matchSavedTheme(listed.themes, query); - if (!matched.ok) fail(matched.error); - const resolved = await backend(); - const result = - resolved.kind === "tray" - ? await request({ - method: "POST", - path: "/theme/use", - body: { id: matched.theme.id }, - signal, - }) - : await resolved.session.useSavedTheme(matched.theme.id); - return { - ...unwrapApply( - result, - matched.theme.type ?? null, - `已切换到主题「${matched.theme.name}」。`, - ), - theme: matched.theme, - }; - }, - - async setFish(enabled, signal) { - const resolved = await backend(); - const want = Boolean(enabled); - const result = - resolved.kind === "tray" - ? await request({ - method: "POST", - path: "/mode/fish", - body: { enabled: want }, - signal, - timeoutMs: QUICK_TIMEOUT_MS, - }) - : await resolved.session.setFishMode(want); - if (result.ok === false) fail(result.error || "无法切换摸鱼模式。"); - return { - ok: true, - fish: result.fish === true, - message: result.fish ? "已进入摸鱼模式。" : "已退出摸鱼模式。", - }; - }, - - async setMuted(muted, signal) { - const resolved = await backend(); - const want = Boolean(muted); - const result = - resolved.kind === "tray" - ? await request({ - method: "POST", - path: "/mode/muted", - body: { muted: want }, - signal, - timeoutMs: QUICK_TIMEOUT_MS, - }) - : await resolved.session.setMuted(want); - if (result.ok === false) fail(result.error || "无法切换背景声音。"); - const blocked = result.blocked === true; - return { - ok: true, - muted: result.muted !== false, - blocked, - message: blocked - ? "浏览器阻止了开启声音,视频将继续静音播放。" - : result.muted - ? "背景视频已静音。" - : "背景视频声音已打开。", - }; - }, - }; -} - -export async function runBgCommand(dataRootOrOptions, rawInput, signal) { - const actions = createBeauticodeActions(dataRootOrOptions); - const value = stripPathQuotes(rawInput); - if (!value) { - try { - const status = await actions.status(signal); - return `${status.message}\n${BG_USAGE}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `${message}\n${BG_USAGE}`; - } - } - const inspected = await inspectLocalMedia(value); - if (!inspected.ok) fail(inspected.error); - if (inspected.kind === "video") { - const result = await actions.applyVideo({ path: inspected.path }, signal); - return result.message; - } - const result = await actions.applyImage(inspected.path, signal); - return result.message; -} - -function registerOne(register, definition) { - try { - register(definition); - } catch { - /* One bad schema or duplicate name must not drop the rest. */ - } -} - -function registerBeauticodeTools(ctx, options) { - const tools = ctx.tools; - if (!tools || typeof tools.register !== "function") return; - const actions = createBeauticodeActions(options); - - registerOne(tools.register.bind(tools), { - name: "beauticode_apply_video", - description: - "把本机 MP4 设为 DeepSeek Harness 网页背景。path 必须是绝对路径。不需要 beautiCode 托盘。", - parameters: { - type: "object", - additionalProperties: false, - properties: { - path: { type: "string", description: "本机 MP4 的绝对路径。" }, - poster: { type: "string", description: "可选海报图片的绝对路径。" }, - startAt: { type: "number", description: "可选起始播放位置(秒)。" }, - }, - required: ["path"], - }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: APPLY_TIMEOUT_MS, - async execute(args, exec) { - return actions.applyVideo( - { - path: args?.path, - poster: args?.poster, - startAt: args?.startAt, - }, - exec?.signal, - ); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_apply_image", - description: - "把本机图片设为 DeepSeek Harness 网页背景。path 必须是绝对路径。不需要 beautiCode 托盘。", - parameters: { - type: "object", - additionalProperties: false, - properties: { - path: { type: "string", description: "本机图片的绝对路径(jpg / jpeg / png / webp / avif)。" }, - }, - required: ["path"], - }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: APPLY_TIMEOUT_MS, - async execute(args, exec) { - return actions.applyImage(args?.path, exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_theme_list", - description: "列出 beautiCode 已保存的背景主题。", - parameters: { type: "object", additionalProperties: false, properties: {} }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: QUICK_TIMEOUT_MS, - async execute(_args, exec) { - return actions.listThemes(exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_theme_use", - description: "按名称或 ID 切换已保存的 beautiCode 背景主题。", - parameters: { - type: "object", - additionalProperties: false, - properties: { - name: { type: "string", description: "主题名称或 ID。" }, - }, - required: ["name"], - }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: APPLY_TIMEOUT_MS, - async execute(args, exec) { - return actions.useTheme(args?.name, exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_clear", - description: "清除 DeepSeek Harness 上的 beautiCode 背景。", - parameters: { type: "object", additionalProperties: false, properties: {} }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: APPLY_TIMEOUT_MS, - async execute(_args, exec) { - return actions.clear(exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_status", - description: "查看当前 beautiCode 背景、摸鱼和声音状态。", - parameters: { type: "object", additionalProperties: false, properties: {} }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: QUICK_TIMEOUT_MS, - async execute(_args, exec) { - return actions.status(exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_set_fish", - description: "打开或关闭摸鱼模式(隐藏 DSH 界面,只留背景)。需要已经有背景。", - parameters: { - type: "object", - additionalProperties: false, - properties: { - enabled: { type: "boolean", description: "true 进入摸鱼,false 退出。" }, - }, - required: ["enabled"], - }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: QUICK_TIMEOUT_MS, - async execute(args, exec) { - if (typeof args?.enabled !== "boolean") fail("enabled 必须是布尔值。"); - return actions.setFish(args.enabled, exec?.signal); - }, - }); - - registerOne(tools.register.bind(tools), { - name: "beauticode_set_muted", - description: "打开或关闭背景视频声音。默认静音。", - parameters: { - type: "object", - additionalProperties: false, - properties: { - muted: { type: "boolean", description: "true 静音,false 尝试开声音。" }, - }, - required: ["muted"], - }, - output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, - timeoutMs: QUICK_TIMEOUT_MS, - async execute(args, exec) { - if (typeof args?.muted !== "boolean") fail("muted 必须是布尔值。"); - return actions.setMuted(args.muted, exec?.signal); - }, - }); - - try { - const prompt = typeof ctx.get === "function" ? ctx.get("systemPrompt") : ctx.systemPrompt; - if (prompt && typeof prompt.section === "function") { - prompt.section({ - name: "tool:beauticode", - order: 160, - text: - "beautiCode 可以把本机图片或 MP4 设为当前 DeepSeek Harness 页面背景。用户要换背景、导入视频或壁纸、切换已保存主题、摸鱼或开关背景声音时,调用 beauticode_* 工具,不要用 shell 改文件或 curl。路径必须是本机绝对路径。用户也可以直接输入斜杠命令 /bg、/bg-theme、/bg-clear。不需要 beautiCode 托盘。", - }); - } - } catch { - /* Prompt guidance is optional. */ - } -} - -function registerBeauticodeCommands(ctx, options) { - const commands = ctx.commands; - if (!commands || typeof commands.register !== "function") return; - const actions = createBeauticodeActions(options); - - registerOne(commands.register.bind(commands), { - name: "bg", - description: "把本机图片或 MP4 设为 beautiCode 背景", - input: { hint: "本机图片或 MP4 的绝对路径" }, - async handler({ rawInput, signal }) { - try { - return { kind: "success", text: await runBgCommand(options, rawInput, signal) }; - } catch (error) { - return commandResultFromError(error); - } - }, - }); - - registerOne(commands.register.bind(commands), { - name: "bg-theme", - description: "切换已保存的 beautiCode 背景主题", - input: { hint: "主题名称" }, - async handler({ rawInput, signal }) { - try { - const name = stripPathQuotes(rawInput); - if (!name) { - const listed = await actions.listThemes(signal); - return { kind: "success", text: `${listed.message} 用法:/bg-theme <名称>` }; - } - const result = await actions.useTheme(name, signal); - return { kind: "success", text: result.message }; - } catch (error) { - return commandResultFromError(error); - } - }, - }); - - registerOne(commands.register.bind(commands), { - name: "bg-clear", - description: "清除 beautiCode 背景", - async handler({ signal }) { - try { - const result = await actions.clear(signal); - return { kind: "success", text: result.message }; - } catch (error) { - return commandResultFromError(error); - } - }, - }); -} - -export function registerAgentSurfaces(ctx, options = {}) { - if (!ctx || typeof ctx.inject !== "function") return; - const dataRoot = options.dataRoot; - if (typeof dataRoot !== "string" || !dataRoot) return; - const actionOptions = { - dataRoot, - baseUrl: options.baseUrl, - }; - if (typeof ctx.effect === "function") { - ctx.effect(() => () => { - void stopInProcessSession(dataRoot); - }); - } - try { - ctx.inject(["tools"], (inner) => { - registerBeauticodeTools(inner, actionOptions); - }); - } catch { - /* Keep the page bridge alive if this DSH build has no tool registry. */ - } - try { - ctx.inject(["commands"], (inner) => { - registerBeauticodeCommands(inner, actionOptions); - }); - } catch { - /* Same: slash commands are optional on a webServer-only composition. */ - } -} +import path from "node:path"; +import { + callDshControl, + formatStatusText, + inspectLocalMedia, + matchSavedTheme, + stripPathQuotes, +} from "./control-client.mjs"; +import { loadAdapter, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs"; +import { ATMOSPHERE_PRESETS, effectsForPreset, presetImagePath } from "./presets.mjs"; + +const APPLY_TIMEOUT_MS = 180_000; +const QUICK_TIMEOUT_MS = 15_000; + +const RESULT_SCHEMA = { + type: "object", + additionalProperties: true, + properties: { + ok: { type: "boolean" }, + message: { type: "string" }, + }, + required: ["ok", "message"], +}; + +const BG_USAGE = + "用法:/bg <本机图片或 MP4 绝对路径>。切换主题用 /bg-theme <名称>,清除用 /bg-clear。"; + +function asText(message) { + return [{ type: "text", text: String(message) }]; +} + +function fail(error) { + throw error instanceof Error ? error : new Error(String(error)); +} + +export function themeNameFromFilePath(filePath, fallback = "主题") { + const base = path.basename(String(filePath ?? "").replaceAll("\\", "/")); + const ext = path.extname(base); + let name = (ext ? base.slice(0, -ext.length) : base).trim(); + name = name + .replace(/[<>:"/\\|?*]/g, " ") + .replace(/[\u0000-\u001f]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!name) name = fallback; + if (name.length > 80) name = name.slice(0, 80).trim(); + if (!name) name = fallback; + return name; +} + +async function chineseError(error) { + const raw = error instanceof Error ? error.message : String(error ?? ""); + try { + const adapter = await loadAdapter(); + if (typeof adapter.toChineseErrorMessage === "function") { + return adapter.toChineseErrorMessage(error); + } + } catch { + /* keep raw */ + } + return raw || "操作失败。"; +} + +async function unwrapApplyResult(result, fallbackMode, message) { + if (!result || result.ok === false) { + const failure = new Error(await chineseError(result?.error || "操作失败。")); + if (result?.sourceMode != null) failure.sourceMode = result.sourceMode; + if (result?.timings != null) failure.timings = result.timings; + throw failure; + } + return { + ok: true, + generation: result.generation ?? null, + mode: result.mode ?? fallbackMode, + sourceMode: result.sourceMode ?? null, + timings: result.timings ?? null, + message, + ...(result.theme + ? { + theme: { + id: result.theme.id, + name: result.theme.name, + type: result.theme.type ?? null, + }, + } + : {}), + }; +} + +function commandResultFromError(error) { + return { + kind: "error", + text: error instanceof Error ? error.message : String(error), + }; +} + +function normalizeActionOptions(dataRootOrOptions) { + if (typeof dataRootOrOptions === "string") { + return { dataRoot: dataRootOrOptions }; + } + return dataRootOrOptions && typeof dataRootOrOptions === "object" + ? dataRootOrOptions + : {}; +} + +function unwrapApply(result, fallbackMode, message) { + if (!result || result.ok === false) { + fail(result?.error || "操作失败。"); + } + return { + ok: true, + generation: result.generation ?? null, + mode: result.mode ?? fallbackMode, + message, + ...(result.theme + ? { + theme: { + id: result.theme.id, + name: result.theme.name, + type: result.theme.type ?? null, + }, + } + : {}), + }; +} + +function presentStatus(status) { + const background = status.manifest?.background ?? status.background ?? null; + return { + ok: true, + hostReady: status.hostReady === true || status.sessions > 0, + sessions: status.sessions ?? 0, + fish: status.fish === true, + muted: status.muted !== false, + tone: status.tone ?? "dark", + background, + sourceMode: background + ? background.source?.kind === "local" + ? "local" + : "managed" + : "clear", + themeId: typeof status.themeId === "string" && status.themeId ? status.themeId : null, + message: formatStatusText(status), + }; +} + +function presentThemes(themes) { + const list = Array.isArray(themes) ? themes : []; + return { + ok: true, + themes: list, + message: + list.length === 0 + ? "还没有已保存的主题。" + : `已保存主题:${list.map((theme) => theme.name).join("、")}。`, + }; +} + +export function createBeauticodeActions(dataRootOrOptions) { + const options = normalizeActionOptions(dataRootOrOptions); + const dataRoot = options.dataRoot; + const request = (spec) => + callDshControl(dataRoot, { + timeoutMs: spec.timeoutMs ?? APPLY_TIMEOUT_MS, + ...spec, + }); + + async function backend() { + return resolveApplyBackend(options); + } + + return { + async applyImage(imagePath, signal, options) { + const inspected = await inspectLocalMedia(imagePath); + if (!inspected.ok) fail(inspected.error); + if (inspected.kind !== "image") { + fail("beauticode_apply_image 只接受图片文件。"); + } + const effects = effectsForPreset(options?.effects?.preset) || options?.effects || null; + const persistTheme = options?.persistTheme !== false; + const themeName = + String(options?.themeName ?? "").trim() || + themeNameFromFilePath(inspected.path, "图片"); + const source = options?.source === "managed" ? "managed" : "local"; + const input = { type: "image", imagePath: inspected.path, source }; + if (effects) input.effects = effects; + const resolved = await backend(); + if (resolved.kind === "tray") { + const result = await request({ + method: "POST", + path: persistTheme ? "/theme/apply" : "/apply/image", + body: persistTheme + ? { name: themeName, input } + : { + imagePath: inspected.path, + source, + ...(effects ? { effects } : {}), + }, + signal, + }); + return unwrapApplyResult( + result, + "image", + persistTheme + ? `已将「${result.theme?.name || themeName}」设为背景。` + : "已将图片设为背景。", + ); + } + const applied = persistTheme + ? await resolved.session.applyAndSaveTheme(input, themeName) + : await resolved.session.apply(input); + return unwrapApplyResult( + applied, + "image", + persistTheme + ? `已将「${applied.theme?.name || themeName}」设为背景。` + : "已将图片设为背景。", + ); + }, + + async applyPreset(id, signal) { + const preset = ATMOSPHERE_PRESETS[id]; + const imagePath = presetImagePath(id); + if (!preset || !imagePath) fail("未找到内置主题文件。"); + const result = await this.applyImage(imagePath, signal, { + effects: effectsForPreset(id), + persistTheme: false, + }); + try { + await this.setTone(preset.tone, signal); + } catch { + /* tone is best-effort; the wallpaper still applied */ + } + return { + ...result, + atmosphere: id, + message: `已应用 ${preset.name} 活壁纸。`, + }; + }, + + async setTone(tone, signal) { + const resolved = await backend(); + const result = + resolved.kind === "tray" + ? await request({ + method: "POST", + path: "/mode/tone", + body: { tone }, + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }) + : await resolved.session.setBackgroundTone(tone); + if (result.ok === false) fail(result.error || "无法切换背景色调。"); + return { ok: true, tone: result.tone ?? tone }; + }, + + async applyVideo(input, signal) { + const inspected = await inspectLocalMedia(input.path); + if (!inspected.ok) fail(inspected.error); + if (inspected.kind !== "video") { + fail("beauticode_apply_video 只接受 .mp4 文件。"); + } + const persistTheme = input?.persistTheme !== false; + const themeName = + String(input?.themeName ?? "").trim() || + themeNameFromFilePath(inspected.path, "视频"); + const source = input?.source === "managed" ? "managed" : "local"; + const body = { videoPath: inspected.path, persistTheme, themeName, source }; + const localInput = { type: "video", videoPath: inspected.path, source }; + if (typeof input.poster === "string" && input.poster.trim()) { + const poster = await inspectLocalMedia(input.poster); + if (!poster.ok) fail(poster.error); + if (poster.kind !== "image") fail("poster 必须是图片文件。"); + body.imagePath = poster.path; + localInput.imagePath = poster.path; + } + if (input.startAt != null && input.startAt !== "") { + const startAt = Number(input.startAt); + if (!Number.isFinite(startAt) || startAt < 0) { + fail("startAt 必须是非负数字(秒)。"); + } + body.startAt = startAt; + localInput.startAt = startAt; + } + const resolved = await backend(); + if (resolved.kind === "tray") { + const result = await request({ + method: "POST", + path: persistTheme ? "/theme/apply" : "/apply/video", + body: persistTheme ? { name: themeName, input: localInput } : body, + signal, + }); + return unwrapApplyResult( + result, + "video", + persistTheme + ? `已将「${result.theme?.name || themeName}」设为背景。` + : "已将视频设为背景。", + ); + } + const applied = persistTheme + ? await resolved.session.applyAndSaveTheme(localInput, themeName) + : await resolved.session.apply(localInput); + return unwrapApplyResult( + applied, + "video", + persistTheme + ? `已将「${applied.theme?.name || themeName}」设为背景。` + : "已将视频设为背景。", + ); + }, + + async clear(signal) { + const resolved = await backend(); + if (resolved.kind === "tray") { + return unwrapApply( + await request({ + method: "POST", + path: "/apply/clear", + body: {}, + signal, + }), + "clear", + "已清除背景。", + ); + } + return unwrapApply(await resolved.session.apply({ type: "clear" }), "clear", "已清除背景。"); + }, + + async status(signal) { + const resolved = await backend(); + if (resolved.kind === "tray") { + return presentStatus( + await request({ + method: "GET", + path: "/status", + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }), + ); + } + const status = await resolved.session.status(); + return presentStatus({ + ...status, + hostReady: resolved.session.isHostReady, + }); + }, + + async importTheme(input, signal) { + const name = String(input?.name ?? "").trim(); + const imagePath = String(input?.imagePath ?? "").trim(); + if (!name || !imagePath) fail("导入皮肤必须提供名称和图片。"); + const body = { + name, + imagePath, + }; + if (typeof input.videoPath === "string" && input.videoPath.trim()) { + body.videoPath = input.videoPath.trim(); + } + if (input.effects) body.effects = input.effects; + if (input.source) body.source = input.source; + const resolved = await backend(); + if (resolved.kind === "tray") { + const result = await request({ + method: "POST", + path: "/theme/import", + body, + signal, + timeoutMs: 30 * 60 * 1000, + }); + if (!result || result.ok === false) fail(result?.error || "导入皮肤失败。"); + return { + ok: true, + theme: result.theme, + message: `已保存皮肤「${result.theme.name}」。`, + }; + } + const theme = await resolved.session.importSavedTheme(body); + return { ok: true, theme, message: `已保存皮肤「${theme.name}」。` }; + }, + + async listThemes(signal) { + const resolved = await backend(); + if (resolved.kind === "tray") { + const result = await request({ + method: "GET", + path: "/theme/list", + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }); + return presentThemes(result.themes); + } + return presentThemes(await resolved.session.listSavedThemes()); + }, + + async useTheme(query, signal) { + const listed = await this.listThemes(signal); + const matched = matchSavedTheme(listed.themes, query); + if (!matched.ok) fail(matched.error); + const resolved = await backend(); + const result = + resolved.kind === "tray" + ? await request({ + method: "POST", + path: "/theme/use", + body: { id: matched.theme.id }, + signal, + }) + : await resolved.session.useSavedTheme(matched.theme.id); + return { + ...unwrapApply( + result, + matched.theme.type ?? null, + `已切换到主题「${matched.theme.name}」。`, + ), + theme: matched.theme, + }; + }, + + async deleteTheme(id, signal) { + const themeId = String(id ?? "").trim(); + if (!themeId) fail("必须提供主题。"); + const resolved = await backend(); + if (resolved.kind === "tray") { + const result = await request({ + method: "POST", + path: "/theme/delete", + body: { id: themeId }, + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }); + if (!result || result.ok === false) { + fail(await chineseError(result?.error || "删除主题失败。")); + } + return { ok: true, deleted: true, message: "已删除主题。" }; + } + try { + const deleted = await resolved.session.deleteSavedTheme(themeId); + if (!deleted) fail("未找到已保存的主题。"); + return { ok: true, deleted: true, message: "已删除主题。" }; + } catch (error) { + fail(await chineseError(error)); + } + }, + + async setFish(enabled, signal) { + const resolved = await backend(); + const want = Boolean(enabled); + const result = + resolved.kind === "tray" + ? await request({ + method: "POST", + path: "/mode/fish", + body: { enabled: want }, + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }) + : await resolved.session.setFishMode(want); + if (result.ok === false) fail(result.error || "无法切换摸鱼模式。"); + return { + ok: true, + fish: result.fish === true, + message: result.fish ? "已进入摸鱼模式。" : "已退出摸鱼模式。", + }; + }, + + async setMuted(muted, signal) { + const resolved = await backend(); + const want = Boolean(muted); + const result = + resolved.kind === "tray" + ? await request({ + method: "POST", + path: "/mode/muted", + body: { muted: want }, + signal, + timeoutMs: QUICK_TIMEOUT_MS, + }) + : await resolved.session.setMuted(want); + if (result.ok === false) fail(result.error || "无法切换背景声音。"); + const blocked = result.blocked === true; + return { + ok: true, + muted: result.muted !== false, + blocked, + message: blocked + ? "浏览器阻止了开启声音,视频将继续静音播放。" + : result.muted + ? "背景视频已静音。" + : "背景视频声音已打开。", + }; + }, + }; +} + +export async function runBgCommand(dataRootOrOptions, rawInput, signal) { + const actions = createBeauticodeActions(dataRootOrOptions); + const value = stripPathQuotes(rawInput); + if (!value) { + try { + const status = await actions.status(signal); + return `${status.message}\n${BG_USAGE}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `${message}\n${BG_USAGE}`; + } + } + const inspected = await inspectLocalMedia(value); + if (!inspected.ok) fail(inspected.error); + if (inspected.kind === "video") { + const result = await actions.applyVideo({ path: inspected.path }, signal); + return result.message; + } + const result = await actions.applyImage(inspected.path, signal); + return result.message; +} + +function registerOne(register, definition) { + try { + register(definition); + } catch { + /* One bad schema or duplicate name must not drop the rest. */ + } +} + +function registerBeauticodeTools(ctx, options) { + const tools = ctx.tools; + if (!tools || typeof tools.register !== "function") return; + const actions = createBeauticodeActions(options); + + registerOne(tools.register.bind(tools), { + name: "beauticode_apply_video", + description: + "把本机 MP4 设为 DeepSeek Harness 网页背景。path 必须是绝对路径。不需要 beautiCode 托盘。", + parameters: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", description: "本机 MP4 的绝对路径。" }, + poster: { type: "string", description: "可选海报图片的绝对路径。" }, + startAt: { type: "number", description: "可选起始播放位置(秒)。" }, + }, + required: ["path"], + }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: APPLY_TIMEOUT_MS, + async execute(args, exec) { + return actions.applyVideo( + { + path: args?.path, + poster: args?.poster, + startAt: args?.startAt, + }, + exec?.signal, + ); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_apply_image", + description: + "把本机图片设为 DeepSeek Harness 网页背景。path 必须是绝对路径。不需要 beautiCode 托盘。", + parameters: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", description: "本机图片的绝对路径(jpg / jpeg / png / webp / avif)。" }, + }, + required: ["path"], + }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: APPLY_TIMEOUT_MS, + async execute(args, exec) { + return actions.applyImage(args?.path, exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_theme_list", + description: "列出 beautiCode 已保存的背景主题。", + parameters: { type: "object", additionalProperties: false, properties: {} }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: QUICK_TIMEOUT_MS, + async execute(_args, exec) { + return actions.listThemes(exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_theme_use", + description: "按名称或 ID 切换已保存的 beautiCode 背景主题。", + parameters: { + type: "object", + additionalProperties: false, + properties: { + name: { type: "string", description: "主题名称或 ID。" }, + }, + required: ["name"], + }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: APPLY_TIMEOUT_MS, + async execute(args, exec) { + return actions.useTheme(args?.name, exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_clear", + description: "清除 DeepSeek Harness 上的 beautiCode 背景。", + parameters: { type: "object", additionalProperties: false, properties: {} }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: APPLY_TIMEOUT_MS, + async execute(_args, exec) { + return actions.clear(exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_status", + description: "查看当前 beautiCode 背景、摸鱼和声音状态。", + parameters: { type: "object", additionalProperties: false, properties: {} }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: QUICK_TIMEOUT_MS, + async execute(_args, exec) { + return actions.status(exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_set_fish", + description: "打开或关闭摸鱼模式(隐藏 DSH 界面,只留背景)。需要已经有背景。", + parameters: { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean", description: "true 进入摸鱼,false 退出。" }, + }, + required: ["enabled"], + }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: QUICK_TIMEOUT_MS, + async execute(args, exec) { + if (typeof args?.enabled !== "boolean") fail("enabled 必须是布尔值。"); + return actions.setFish(args.enabled, exec?.signal); + }, + }); + + registerOne(tools.register.bind(tools), { + name: "beauticode_set_muted", + description: "打开或关闭背景视频声音。默认静音。", + parameters: { + type: "object", + additionalProperties: false, + properties: { + muted: { type: "boolean", description: "true 静音,false 尝试开声音。" }, + }, + required: ["muted"], + }, + output: { schema: RESULT_SCHEMA, render: (_args, value) => asText(value.message) }, + timeoutMs: QUICK_TIMEOUT_MS, + async execute(args, exec) { + if (typeof args?.muted !== "boolean") fail("muted 必须是布尔值。"); + return actions.setMuted(args.muted, exec?.signal); + }, + }); + + try { + const prompt = typeof ctx.get === "function" ? ctx.get("systemPrompt") : ctx.systemPrompt; + if (prompt && typeof prompt.section === "function") { + prompt.section({ + name: "tool:beauticode", + order: 160, + text: + "beautiCode 可以把本机图片或 MP4 设为当前 DeepSeek Harness 页面背景。用户要换背景、导入视频或壁纸、切换已保存主题、摸鱼或开关背景声音时,调用 beauticode_* 工具,不要用 shell 改文件或 curl。路径必须是本机绝对路径。用户也可以直接输入斜杠命令 /bg、/bg-theme、/bg-clear。不需要 beautiCode 托盘。", + }); + } + } catch { + /* Prompt guidance is optional. */ + } +} + +function registerBeauticodeCommands(ctx, options) { + const commands = ctx.commands; + if (!commands || typeof commands.register !== "function") return; + const actions = createBeauticodeActions(options); + + registerOne(commands.register.bind(commands), { + name: "bg", + description: "把本机图片或 MP4 设为 beautiCode 背景", + input: { hint: "本机图片或 MP4 的绝对路径" }, + async handler({ rawInput, signal }) { + try { + return { kind: "success", text: await runBgCommand(options, rawInput, signal) }; + } catch (error) { + return commandResultFromError(error); + } + }, + }); + + registerOne(commands.register.bind(commands), { + name: "bg-theme", + description: "切换已保存的 beautiCode 背景主题", + input: { hint: "主题名称" }, + async handler({ rawInput, signal }) { + try { + const name = stripPathQuotes(rawInput); + if (!name) { + const listed = await actions.listThemes(signal); + return { kind: "success", text: `${listed.message} 用法:/bg-theme <名称>` }; + } + const result = await actions.useTheme(name, signal); + return { kind: "success", text: result.message }; + } catch (error) { + return commandResultFromError(error); + } + }, + }); + + registerOne(commands.register.bind(commands), { + name: "bg-clear", + description: "清除 beautiCode 背景", + async handler({ signal }) { + try { + const result = await actions.clear(signal); + return { kind: "success", text: result.message }; + } catch (error) { + return commandResultFromError(error); + } + }, + }); +} + +export function registerAgentSurfaces(ctx, options = {}) { + if (!ctx || typeof ctx.inject !== "function") return; + const dataRoot = options.dataRoot; + if (typeof dataRoot !== "string" || !dataRoot) return; + const actionOptions = { + dataRoot, + baseUrl: options.baseUrl, + }; + if (typeof ctx.effect === "function") { + ctx.effect(() => () => { + void stopInProcessSession(dataRoot); + }); + } + try { + ctx.inject(["tools"], (inner) => { + registerBeauticodeTools(inner, actionOptions); + }); + } catch { + /* Keep the page bridge alive if this DSH build has no tool registry. */ + } + try { + ctx.inject(["commands"], (inner) => { + registerBeauticodeCommands(inner, actionOptions); + }); + } catch { + /* Same: slash commands are optional on a webServer-only composition. */ + } +} diff --git a/integrations/deepseek-harness/atmosphere.js b/integrations/deepseek-harness/atmosphere.js index 19338c7..cd5947e 100644 --- a/integrations/deepseek-harness/atmosphere.js +++ b/integrations/deepseek-harness/atmosphere.js @@ -1,306 +1,306 @@ -(() => { - "use strict"; - - const STYLE_ID = "beauticode-atmosphere-style"; - const ROOT_ID = "beauticode-gallery-bg"; - const CANVAS_URL = - (typeof globalThis !== "undefined" && globalThis.__BEAUTICODE_CANVAS_URL) || - "/__beauticode/themes/bg-canvas.png?v=uhd"; - - function createWaterSim(simWidth, simHeight) { - const width = Math.max(24, simWidth | 0); - const height = Math.max(16, simHeight | 0); - let current = new Float32Array(width * height); - let next = new Float32Array(width * height); - let clock = 0; - - function poke(cx, cy, strength, radius) { - const x0 = cx | 0; - const y0 = cy | 0; - const rad = Math.max(1, radius); - const r2 = rad * rad; - const reach = Math.ceil(rad); - for (let y = -reach; y <= reach; y += 1) { - for (let x = -reach; x <= reach; x += 1) { - const px = x0 + x; - const py = y0 + y; - if (px < 1 || py < 1 || px >= width - 1 || py >= height - 1) continue; - const falloff = (x * x + y * y) / r2; - if (falloff > 1) continue; - current[py * width + px] += strength * (0.5 + 0.5 * Math.cos(Math.PI * Math.sqrt(falloff))); - } - } - } - - function step(dt) { - clock += dt; - for (let y = 1; y < height - 1; y += 1) { - let i = y * width + 1; - for (let x = 1; x < width - 1; x += 1, i += 1) { - next[i] = - ((current[i - 1] + current[i + 1] + current[i - width] + current[i + width]) * 0.5 - - next[i]) * - 0.9855 + - 0.0024 * Math.sin(clock * 0.7 + x * 0.05 + y * 0.021) + - 0.0019 * Math.sin(clock * 0.43 - x * 0.023 + y * 0.041); - } - } - const swap = current; - current = next; - next = swap; - } - - return { - width, - height, - poke, - step, - heights() { - return current; - }, - }; - } - - function ensureStyle() { - if (typeof document === "undefined") return null; - let style = document.getElementById(STYLE_ID); - if (!style) { - style = document.createElement("style"); - style.id = STYLE_ID; - document.head.append(style); - } - style.textContent = ` -#beauticode-gallery-bg{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#0b1018} -#beauticode-gallery-bg img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center center;display:block;pointer-events:none;z-index:0;image-rendering:auto;-webkit-backface-visibility:hidden} -#beauticode-gallery-bg canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;mix-blend-mode:soft-light;opacity:.32} -html[data-bc-gallery="true"] #beauticode-bg-stage{background:transparent!important} -html[data-bc-gallery="true"] #beauticode-bg-stage::after{display:none!important;background:transparent!important} -html[data-bc-gallery="true"],html[data-bc-gallery="true"] body{background:transparent!important} -html[data-bc-gallery="true"] body{ - --dsw-alias-bg-base:rgba(17,20,27,.10); - --dsw-alias-bg-layer-1:rgba(26,30,39,.28); - --dsw-alias-bg-layer-2:rgba(35,40,51,.32); - --dsw-alias-bg-overlay:rgba(17,20,27,.12); - --dsw-specific-sidebar-fill:rgba(23,27,35,.28); -} -html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{ - --dsw-alias-bg-base:rgba(248,250,252,.12); - --dsw-alias-bg-layer-1:rgba(255,255,255,.28); - --dsw-alias-bg-layer-2:rgba(248,250,252,.32); - --dsw-alias-bg-overlay:rgba(255,255,255,.14); - --dsw-specific-sidebar-fill:rgba(255,255,255,.28); -} -html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important} -html[data-bc-gallery="true"] [class*="_fade"]{display:none!important} -html[data-bc-gallery="true"] #beauticode-bg-stage>img, -html[data-bc-gallery="true"] #beauticode-bg-stage>video{opacity:0!important} -html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} -`; - return style; - } - - const runtime = { - windowMode: "closed", - layer: null, - token: 0, - loop: 0, - lastTick: 0, - }; - - function stopLoop() { - if (runtime.loop) { - cancelAnimationFrame(runtime.loop); - runtime.loop = 0; - } - } - - function attachWater(canvas) { - const dpr = () => Math.max(1, Math.min(2, window.devicePixelRatio || 1)); - let sim = null; - let output = null; - let work = null; - let last = null; - - function sizeSim() { - const width = Math.max(32, canvas.clientWidth || window.innerWidth || 1); - const height = Math.max(32, canvas.clientHeight || window.innerHeight || 1); - const pixel = dpr(); - canvas.width = Math.round(width * pixel); - canvas.height = Math.round(height * pixel); - const simWidth = Math.max(80, Math.min(420, Math.round(width / 4))); - const simHeight = Math.max(45, Math.round(simWidth * (height / width))); - sim = createWaterSim(simWidth, simHeight); - output = null; - work = null; - } - - function localPoint(event) { - const rect = canvas.getBoundingClientRect(); - if (!rect.width || !rect.height || !sim) return null; - return { - x: ((event.clientX - rect.left) / rect.width) * sim.width, - y: ((event.clientY - rect.top) / rect.height) * sim.height, - }; - } - - function follow(event) { - if (!sim) return; - const point = localPoint(event); - if (!point) return; - if (last) { - const dx = point.x - last.x; - const dy = point.y - last.y; - const dist = Math.hypot(dx, dy); - const steps = Math.max(1, Math.min(8, Math.ceil(dist / 6))); - const strength = 0.55 + Math.min(1.8, dist / 18); - for (let i = 1; i <= steps; i += 1) { - const t = i / steps; - sim.poke(last.x + dx * t, last.y + dy * t, strength / steps, 2.4); - } - } else { - sim.poke(point.x, point.y, 0.7, 2.2); - } - last = point; - } - - function render() { - if (!sim) return; - sim.step(0.033); - const ctx = canvas.getContext("2d"); - if (!output) output = ctx.createImageData(sim.width, sim.height); - const dest = output.data; - const heights = sim.heights(); - for (let y = 0; y < sim.height; y += 1) { - const up = y > 0 ? y - 1 : y; - const down = y < sim.height - 1 ? y + 1 : y; - for (let x = 0; x < sim.width; x += 1) { - const i = y * sim.width + x; - const gx = heights[x > 0 ? i - 1 : i] - heights[x < sim.width - 1 ? i + 1 : i]; - const gy = heights[up * sim.width + x] - heights[down * sim.width + x]; - const di = i * 4; - let alpha = gy * 160; - if (alpha >= 0) { - dest[di] = 224; - dest[di + 1] = 238; - dest[di + 2] = 255; - dest[di + 3] = alpha > 110 ? 110 : alpha; - } else { - alpha = -alpha; - dest[di] = 8; - dest[di + 1] = 16; - dest[di + 2] = 32; - dest[di + 3] = alpha > 90 ? 90 : alpha; - } - } - } - if (!work) { - work = document.createElement("canvas"); - work.width = sim.width; - work.height = sim.height; - } - work.getContext("2d").putImageData(output, 0, 0); - const pixel = dpr(); - ctx.setTransform(pixel, 0, 0, pixel, 0, 0); - ctx.imageSmoothingEnabled = true; - if (ctx.imageSmoothingQuality) ctx.imageSmoothingQuality = "high"; - ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); - ctx.drawImage(work, 0, 0, canvas.clientWidth, canvas.clientHeight); - } - - sizeSim(); - window.addEventListener("pointermove", follow, { passive: true }); - window.addEventListener("resize", sizeSim); - - return { - render, - destroy() { - window.removeEventListener("pointermove", follow); - window.removeEventListener("resize", sizeSim); - }, - }; - } - - function markPage(on) { - const root = document.documentElement; - if (on) { - root.dataset.bcGallery = "true"; - root.dataset.bcActive = "true"; - } else { - delete root.dataset.bcGallery; - if (!document.querySelector("#beauticode-bg-stage img, #beauticode-bg-stage video")) { - root.removeAttribute("data-bc-active"); - } - } - } - - function closeLayer() { - stopLoop(); - runtime.layer?.water?.destroy(); - runtime.layer?.node.remove(); - runtime.layer = null; - markPage(false); - } - - async function openLayer() { - const token = (runtime.token += 1); - ensureStyle(); - closeLayer(); - markPage(true); - const node = document.createElement("div"); - node.id = ROOT_ID; - node.setAttribute("aria-hidden", "true"); - const image = document.createElement("img"); - image.alt = ""; - image.decoding = "async"; - image.fetchPriority = "high"; - image.draggable = false; - const canvas = document.createElement("canvas"); - node.append(image, canvas); - document.body.prepend(node); - - await new Promise((resolve) => { - image.onload = resolve; - image.onerror = resolve; - image.src = CANVAS_URL; - }); - if (token !== runtime.token || runtime.windowMode === "closed") { - node.remove(); - return; - } - - const water = attachWater(canvas); - runtime.layer = { node, water }; - const tick = (ts) => { - if (!runtime.layer) return; - if (ts - runtime.lastTick > 32) { - runtime.lastTick = ts; - water.render(); - } - runtime.loop = requestAnimationFrame(tick); - }; - runtime.loop = requestAnimationFrame(tick); - } - - function setWindowMode(mode) { - const next = mode === "on" || mode === "window" || mode === "full" ? "on" : "closed"; - runtime.windowMode = next; - if (next === "closed") closeLayer(); - else void openLayer(); - return next; - } - - function getState() { - return { windowMode: runtime.windowMode }; - } - - const api = { - createWaterSim, - setWindowMode, - getState, - }; - - globalThis.BeauticodeAtmosphere = api; - if (typeof module !== "undefined" && module.exports) { - module.exports = api; - } -})(); +(() => { + "use strict"; + + const STYLE_ID = "beauticode-atmosphere-style"; + const ROOT_ID = "beauticode-gallery-bg"; + const CANVAS_URL = + (typeof globalThis !== "undefined" && globalThis.__BEAUTICODE_CANVAS_URL) || + "/__beauticode/themes/bg-canvas.png?v=uhd"; + + function createWaterSim(simWidth, simHeight) { + const width = Math.max(24, simWidth | 0); + const height = Math.max(16, simHeight | 0); + let current = new Float32Array(width * height); + let next = new Float32Array(width * height); + let clock = 0; + + function poke(cx, cy, strength, radius) { + const x0 = cx | 0; + const y0 = cy | 0; + const rad = Math.max(1, radius); + const r2 = rad * rad; + const reach = Math.ceil(rad); + for (let y = -reach; y <= reach; y += 1) { + for (let x = -reach; x <= reach; x += 1) { + const px = x0 + x; + const py = y0 + y; + if (px < 1 || py < 1 || px >= width - 1 || py >= height - 1) continue; + const falloff = (x * x + y * y) / r2; + if (falloff > 1) continue; + current[py * width + px] += strength * (0.5 + 0.5 * Math.cos(Math.PI * Math.sqrt(falloff))); + } + } + } + + function step(dt) { + clock += dt; + for (let y = 1; y < height - 1; y += 1) { + let i = y * width + 1; + for (let x = 1; x < width - 1; x += 1, i += 1) { + next[i] = + ((current[i - 1] + current[i + 1] + current[i - width] + current[i + width]) * 0.5 - + next[i]) * + 0.9855 + + 0.0024 * Math.sin(clock * 0.7 + x * 0.05 + y * 0.021) + + 0.0019 * Math.sin(clock * 0.43 - x * 0.023 + y * 0.041); + } + } + const swap = current; + current = next; + next = swap; + } + + return { + width, + height, + poke, + step, + heights() { + return current; + }, + }; + } + + function ensureStyle() { + if (typeof document === "undefined") return null; + let style = document.getElementById(STYLE_ID); + if (!style) { + style = document.createElement("style"); + style.id = STYLE_ID; + document.head.append(style); + } + style.textContent = ` +#beauticode-gallery-bg{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#0b1018} +#beauticode-gallery-bg img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center center;display:block;pointer-events:none;z-index:0;image-rendering:auto;-webkit-backface-visibility:hidden} +#beauticode-gallery-bg canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;mix-blend-mode:soft-light;opacity:.32} +html[data-bc-gallery="true"] #beauticode-bg-stage{background:transparent!important} +html[data-bc-gallery="true"] #beauticode-bg-stage::after{display:none!important;background:transparent!important} +html[data-bc-gallery="true"],html[data-bc-gallery="true"] body{background:transparent!important} +html[data-bc-gallery="true"] body{ + --dsw-alias-bg-base:rgba(17,20,27,.10); + --dsw-alias-bg-layer-1:rgba(26,30,39,.28); + --dsw-alias-bg-layer-2:rgba(35,40,51,.32); + --dsw-alias-bg-overlay:rgba(17,20,27,.12); + --dsw-specific-sidebar-fill:rgba(23,27,35,.28); +} +html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{ + --dsw-alias-bg-base:rgba(248,250,252,.12); + --dsw-alias-bg-layer-1:rgba(255,255,255,.28); + --dsw-alias-bg-layer-2:rgba(248,250,252,.32); + --dsw-alias-bg-overlay:rgba(255,255,255,.14); + --dsw-specific-sidebar-fill:rgba(255,255,255,.28); +} +html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important} +html[data-bc-gallery="true"] [class*="_fade"]{display:none!important} +html[data-bc-gallery="true"] #beauticode-bg-stage img, +html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important} +html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} +`; + return style; + } + + const runtime = { + windowMode: "closed", + layer: null, + token: 0, + loop: 0, + lastTick: 0, + }; + + function stopLoop() { + if (runtime.loop) { + cancelAnimationFrame(runtime.loop); + runtime.loop = 0; + } + } + + function attachWater(canvas) { + const dpr = () => Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + let sim = null; + let output = null; + let work = null; + let last = null; + + function sizeSim() { + const width = Math.max(32, canvas.clientWidth || window.innerWidth || 1); + const height = Math.max(32, canvas.clientHeight || window.innerHeight || 1); + const pixel = dpr(); + canvas.width = Math.round(width * pixel); + canvas.height = Math.round(height * pixel); + const simWidth = Math.max(80, Math.min(420, Math.round(width / 4))); + const simHeight = Math.max(45, Math.round(simWidth * (height / width))); + sim = createWaterSim(simWidth, simHeight); + output = null; + work = null; + } + + function localPoint(event) { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height || !sim) return null; + return { + x: ((event.clientX - rect.left) / rect.width) * sim.width, + y: ((event.clientY - rect.top) / rect.height) * sim.height, + }; + } + + function follow(event) { + if (!sim) return; + const point = localPoint(event); + if (!point) return; + if (last) { + const dx = point.x - last.x; + const dy = point.y - last.y; + const dist = Math.hypot(dx, dy); + const steps = Math.max(1, Math.min(8, Math.ceil(dist / 6))); + const strength = 0.55 + Math.min(1.8, dist / 18); + for (let i = 1; i <= steps; i += 1) { + const t = i / steps; + sim.poke(last.x + dx * t, last.y + dy * t, strength / steps, 2.4); + } + } else { + sim.poke(point.x, point.y, 0.7, 2.2); + } + last = point; + } + + function render() { + if (!sim) return; + sim.step(0.033); + const ctx = canvas.getContext("2d"); + if (!output) output = ctx.createImageData(sim.width, sim.height); + const dest = output.data; + const heights = sim.heights(); + for (let y = 0; y < sim.height; y += 1) { + const up = y > 0 ? y - 1 : y; + const down = y < sim.height - 1 ? y + 1 : y; + for (let x = 0; x < sim.width; x += 1) { + const i = y * sim.width + x; + const gx = heights[x > 0 ? i - 1 : i] - heights[x < sim.width - 1 ? i + 1 : i]; + const gy = heights[up * sim.width + x] - heights[down * sim.width + x]; + const di = i * 4; + let alpha = gy * 160; + if (alpha >= 0) { + dest[di] = 224; + dest[di + 1] = 238; + dest[di + 2] = 255; + dest[di + 3] = alpha > 110 ? 110 : alpha; + } else { + alpha = -alpha; + dest[di] = 8; + dest[di + 1] = 16; + dest[di + 2] = 32; + dest[di + 3] = alpha > 90 ? 90 : alpha; + } + } + } + if (!work) { + work = document.createElement("canvas"); + work.width = sim.width; + work.height = sim.height; + } + work.getContext("2d").putImageData(output, 0, 0); + const pixel = dpr(); + ctx.setTransform(pixel, 0, 0, pixel, 0, 0); + ctx.imageSmoothingEnabled = true; + if (ctx.imageSmoothingQuality) ctx.imageSmoothingQuality = "high"; + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + ctx.drawImage(work, 0, 0, canvas.clientWidth, canvas.clientHeight); + } + + sizeSim(); + window.addEventListener("pointermove", follow, { passive: true }); + window.addEventListener("resize", sizeSim); + + return { + render, + destroy() { + window.removeEventListener("pointermove", follow); + window.removeEventListener("resize", sizeSim); + }, + }; + } + + function markPage(on) { + const root = document.documentElement; + if (on) { + root.dataset.bcGallery = "true"; + root.dataset.bcActive = "true"; + } else { + delete root.dataset.bcGallery; + if (!document.querySelector("#beauticode-bg-stage img, #beauticode-bg-stage video")) { + root.removeAttribute("data-bc-active"); + } + } + } + + function closeLayer() { + stopLoop(); + runtime.layer?.water?.destroy(); + runtime.layer?.node.remove(); + runtime.layer = null; + markPage(false); + } + + async function openLayer() { + const token = (runtime.token += 1); + ensureStyle(); + closeLayer(); + markPage(true); + const node = document.createElement("div"); + node.id = ROOT_ID; + node.setAttribute("aria-hidden", "true"); + const image = document.createElement("img"); + image.alt = ""; + image.decoding = "async"; + image.fetchPriority = "high"; + image.draggable = false; + const canvas = document.createElement("canvas"); + node.append(image, canvas); + document.body.prepend(node); + + await new Promise((resolve) => { + image.onload = resolve; + image.onerror = resolve; + image.src = CANVAS_URL; + }); + if (token !== runtime.token || runtime.windowMode === "closed") { + node.remove(); + return; + } + + const water = attachWater(canvas); + runtime.layer = { node, water }; + const tick = (ts) => { + if (!runtime.layer) return; + if (ts - runtime.lastTick > 32) { + runtime.lastTick = ts; + water.render(); + } + runtime.loop = requestAnimationFrame(tick); + }; + runtime.loop = requestAnimationFrame(tick); + } + + function setWindowMode(mode) { + const next = mode === "on" || mode === "window" || mode === "full" ? "on" : "closed"; + runtime.windowMode = next; + if (next === "closed") closeLayer(); + else void openLayer(); + return next; + } + + function getState() { + return { windowMode: runtime.windowMode }; + } + + const api = { + createWaterSim, + setWindowMode, + getState, + }; + + globalThis.BeauticodeAtmosphere = api; + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } +})(); diff --git a/integrations/deepseek-harness/bin/beauticode-dsh b/integrations/deepseek-harness/bin/beauticode-dsh index e1cbe2c..dedf9d3 100644 --- a/integrations/deepseek-harness/bin/beauticode-dsh +++ b/integrations/deepseek-harness/bin/beauticode-dsh @@ -1,219 +1,7 @@ -#!/usr/bin/env node -/** - * One-line installer: npx beauticode-dsh - * - * Copies this package to a stable local folder and writes the DSH patch so - * `dsh web` loads beautiCode. Does not start DeepSeek Harness. - */ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const pluginName = "@beauticode/dsh-plugin"; -const bridgeId = "beauticode-bridge"; - -function argValue(argv, name) { - const idx = argv.indexOf(name); - if (idx === -1) return null; - const value = argv[idx + 1]; - return value && !value.startsWith("--") ? value : null; -} - -function defaultDataRoot() { - if (process.env.BEAUTICODE_DATA_ROOT) return process.env.BEAUTICODE_DATA_ROOT; - if (process.env.LOCALAPPDATA) { - return path.join(process.env.LOCALAPPDATA, "beautiCode"); - } - return path.join(os.homedir(), ".beauticode"); -} - -function defaultDshHome() { - return process.env.DSH_HOME || path.join(os.homedir(), ".dsh"); -} - -function toFileUri(filePath) { - const full = path.resolve(filePath).replaceAll("\\", "/"); - if (/^[A-Za-z]:/.test(full)) return `file:///${full}`; - return pathToFileURL(full).href; -} - -function fileUriInsert(uri) { - return [ - "# beauticode-bridge (installer)", - "- insert:", - " - id: beauticode-bridge", - ` name: '${uri}'`, - " inject: [webServer]", - "", - ].join("\n"); -} - -function packageInsert() { - return [ - "# beauticode-bridge (installer)", - "- insert:", - " - id: beauticode-bridge", - ` name: '${pluginName}'`, - " inject: [webServer]", - "", - ].join("\n"); -} - -function hasBridge(text) { - return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text); -} - -function stripBridge(text) { - const patterns = [ - /(?:^|\r?\n)# beauticode-bridge \(installer\)\r?\n- insert:\r?\n(?:[ \t]+.*\r?\n)*/g, - /(?:^|\r?\n)- insert:\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+-\s*id:\s*beauticode-bridge\r?\n(?:[ \t]+.*\r?\n)*/g, - ]; - let next = text; - for (const pattern of patterns) next = next.replace(pattern, "\n"); - return next; -} - -async function writePatch(filePath, body) { - await fsp.mkdir(path.dirname(filePath), { recursive: true }); - if (!fs.existsSync(filePath)) { - await fsp.writeFile(filePath, body, "utf8"); - return; - } - const raw = await fsp.readFile(filePath, "utf8"); - if (hasBridge(raw)) { - const replaced = raw.replace( - /(?:# beauticode-bridge \(installer\)\r?\n)?- insert:\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+-\s*id:\s*beauticode-bridge\r?\n(?:[ \t]+.*\r?\n)*/, - body, - ); - await fsp.writeFile(filePath, replaced.endsWith("\n") ? replaced : `${replaced}\n`, "utf8"); - return; - } - const stripped = raw.trim(); - if (stripped === "" || stripped === "[]") { - await fsp.writeFile(filePath, body, "utf8"); - return; - } - await fsp.writeFile(filePath, `${stripped}\n\n${body}`, "utf8"); -} - -async function removePatch(filePath) { - if (!fs.existsSync(filePath)) return false; - const raw = await fsp.readFile(filePath, "utf8"); - if (!hasBridge(raw)) return false; - const cleaned = stripBridge(raw).trim(); - if (cleaned === "" || cleaned === "[]") { - await fsp.writeFile( - filePath, - "# Your patch layer for this dsh profile.\n[]\n", - "utf8", - ); - return true; - } - await fsp.writeFile(filePath, `${cleaned}\n`, "utf8"); - return true; -} - -function shouldCopy(source) { - const relative = path.relative(here, source); - if (relative.startsWith("test") || relative.includes(`${path.sep}test${path.sep}`)) { - return false; - } - if (relative.endsWith(".test.mjs")) return false; - return true; -} - -async function copyPackage(dest) { - await fsp.rm(dest, { recursive: true, force: true }); - await fsp.cp(here, dest, { - recursive: true, - filter: (source) => shouldCopy(source), - }); -} - -async function ensureEngine(dest) { - const vendor = path.join(dest, "vendor", "adapter-dsh", "index.js"); - if (fs.existsSync(vendor)) return; - const packPath = path.resolve(here, "../../scripts/pack-dsh-plugin.mjs"); - if (!fs.existsSync(packPath)) { - throw new Error("插件包不完整:缺少本机导入引擎。请重新执行 npx beauticode-dsh。"); - } - const { stageEngineInto } = await import(pathToFileURL(packPath).href); - await stageEngineInto(dest); -} - -async function install(opts) { - const dest = path.resolve(opts.pluginHome); - const dshHome = path.resolve(opts.dshHome); - const webProfile = path.join(dshHome, "profiles", "web"); - const webPatch = path.join(webProfile, "cordis.patch.yml"); - const webPackage = path.join(webProfile, "package.json"); - const homePatch = path.join(dshHome, "cordis.patch.yml"); - - await fsp.mkdir(dest, { recursive: true }); - await copyPackage(dest); - await ensureEngine(dest); - const indexFile = path.join(dest, "index.mjs"); - if (!fs.existsSync(indexFile)) { - throw new Error(`缺少插件入口:${indexFile}`); - } - - if (fs.existsSync(webPackage)) { - await writePatch(webPatch, fileUriInsert(toFileUri(indexFile))); - if (fs.existsSync(homePatch)) { - const homeRaw = await fsp.readFile(homePatch, "utf8"); - if (hasBridge(homeRaw)) await removePatch(homePatch); - } - console.log(`已写入 ${webPatch}`); - console.log(`插件已复制到 ${dest}`); - console.log("请自己运行:npx @deepseek-ai/dsh web"); - return { dest, patch: webPatch }; - } - - await fsp.mkdir(dshHome, { recursive: true }); - await writePatch(homePatch, fileUriInsert(toFileUri(indexFile))); - console.log(`DSH web profile 还不存在,已写入 ${homePatch}`); - console.log(`插件已复制到 ${dest}`); - console.log("请自己运行:npx @deepseek-ai/dsh web"); - return { dest, patch: homePatch }; -} - -async function uninstall(opts) { - const dshHome = path.resolve(opts.dshHome); - const removed = []; - if (await removePatch(path.join(dshHome, "profiles", "web", "cordis.patch.yml"))) { - removed.push("web patch"); - } - if (await removePatch(path.join(dshHome, "cordis.patch.yml"))) { - removed.push("home patch"); - } - const dest = path.resolve(opts.pluginHome); - if (fs.existsSync(dest)) { - await fsp.rm(dest, { recursive: true, force: true }); - removed.push(dest); - } - console.log(removed.length ? `已移除:${removed.join("、")}` : "没有可移除的 beautiCode 插件接线。"); - return { removed }; -} - -export async function runCli(argv = process.argv.slice(2)) { - const dshHome = argValue(argv, "--dsh-home") || defaultDshHome(); - const pluginHome = - argValue(argv, "--plugin-home") || path.join(defaultDataRoot(), "plugin"); - const opts = { dshHome, pluginHome }; - if (argv.includes("--remove")) return uninstall(opts); - return install(opts); -} - -const launchedDirectly = - Boolean(process.argv[1]) && - pathToFileURL(path.resolve(process.argv[1])).href.toLowerCase() === - import.meta.url.toLowerCase(); -if (launchedDirectly) { - runCli().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - }); -} +#!/usr/bin/env node +import { runCli } from "../cli.js"; + +runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/integrations/deepseek-harness/cli.js b/integrations/deepseek-harness/cli.js index e1cbe2c..7ea41ab 100644 --- a/integrations/deepseek-harness/cli.js +++ b/integrations/deepseek-harness/cli.js @@ -1,219 +1,442 @@ -#!/usr/bin/env node -/** - * One-line installer: npx beauticode-dsh - * - * Copies this package to a stable local folder and writes the DSH patch so - * `dsh web` loads beautiCode. Does not start DeepSeek Harness. - */ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const pluginName = "@beauticode/dsh-plugin"; -const bridgeId = "beauticode-bridge"; - -function argValue(argv, name) { - const idx = argv.indexOf(name); - if (idx === -1) return null; - const value = argv[idx + 1]; - return value && !value.startsWith("--") ? value : null; -} - -function defaultDataRoot() { - if (process.env.BEAUTICODE_DATA_ROOT) return process.env.BEAUTICODE_DATA_ROOT; - if (process.env.LOCALAPPDATA) { - return path.join(process.env.LOCALAPPDATA, "beautiCode"); - } - return path.join(os.homedir(), ".beauticode"); -} - -function defaultDshHome() { - return process.env.DSH_HOME || path.join(os.homedir(), ".dsh"); -} - -function toFileUri(filePath) { - const full = path.resolve(filePath).replaceAll("\\", "/"); - if (/^[A-Za-z]:/.test(full)) return `file:///${full}`; - return pathToFileURL(full).href; -} - -function fileUriInsert(uri) { - return [ - "# beauticode-bridge (installer)", - "- insert:", - " - id: beauticode-bridge", - ` name: '${uri}'`, - " inject: [webServer]", - "", - ].join("\n"); -} - -function packageInsert() { - return [ - "# beauticode-bridge (installer)", - "- insert:", - " - id: beauticode-bridge", - ` name: '${pluginName}'`, - " inject: [webServer]", - "", - ].join("\n"); -} - -function hasBridge(text) { - return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text); -} - -function stripBridge(text) { - const patterns = [ - /(?:^|\r?\n)# beauticode-bridge \(installer\)\r?\n- insert:\r?\n(?:[ \t]+.*\r?\n)*/g, - /(?:^|\r?\n)- insert:\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+-\s*id:\s*beauticode-bridge\r?\n(?:[ \t]+.*\r?\n)*/g, - ]; - let next = text; - for (const pattern of patterns) next = next.replace(pattern, "\n"); - return next; -} - -async function writePatch(filePath, body) { - await fsp.mkdir(path.dirname(filePath), { recursive: true }); - if (!fs.existsSync(filePath)) { - await fsp.writeFile(filePath, body, "utf8"); - return; - } - const raw = await fsp.readFile(filePath, "utf8"); - if (hasBridge(raw)) { - const replaced = raw.replace( - /(?:# beauticode-bridge \(installer\)\r?\n)?- insert:\r?\n(?:[ \t]+.*\r?\n)*?[ \t]+-\s*id:\s*beauticode-bridge\r?\n(?:[ \t]+.*\r?\n)*/, - body, - ); - await fsp.writeFile(filePath, replaced.endsWith("\n") ? replaced : `${replaced}\n`, "utf8"); - return; - } - const stripped = raw.trim(); - if (stripped === "" || stripped === "[]") { - await fsp.writeFile(filePath, body, "utf8"); - return; - } - await fsp.writeFile(filePath, `${stripped}\n\n${body}`, "utf8"); -} - -async function removePatch(filePath) { - if (!fs.existsSync(filePath)) return false; - const raw = await fsp.readFile(filePath, "utf8"); - if (!hasBridge(raw)) return false; - const cleaned = stripBridge(raw).trim(); - if (cleaned === "" || cleaned === "[]") { - await fsp.writeFile( - filePath, - "# Your patch layer for this dsh profile.\n[]\n", - "utf8", - ); - return true; - } - await fsp.writeFile(filePath, `${cleaned}\n`, "utf8"); - return true; -} - -function shouldCopy(source) { - const relative = path.relative(here, source); - if (relative.startsWith("test") || relative.includes(`${path.sep}test${path.sep}`)) { - return false; - } - if (relative.endsWith(".test.mjs")) return false; - return true; -} - -async function copyPackage(dest) { - await fsp.rm(dest, { recursive: true, force: true }); - await fsp.cp(here, dest, { - recursive: true, - filter: (source) => shouldCopy(source), - }); -} - -async function ensureEngine(dest) { - const vendor = path.join(dest, "vendor", "adapter-dsh", "index.js"); - if (fs.existsSync(vendor)) return; - const packPath = path.resolve(here, "../../scripts/pack-dsh-plugin.mjs"); - if (!fs.existsSync(packPath)) { - throw new Error("插件包不完整:缺少本机导入引擎。请重新执行 npx beauticode-dsh。"); - } - const { stageEngineInto } = await import(pathToFileURL(packPath).href); - await stageEngineInto(dest); -} - -async function install(opts) { - const dest = path.resolve(opts.pluginHome); - const dshHome = path.resolve(opts.dshHome); - const webProfile = path.join(dshHome, "profiles", "web"); - const webPatch = path.join(webProfile, "cordis.patch.yml"); - const webPackage = path.join(webProfile, "package.json"); - const homePatch = path.join(dshHome, "cordis.patch.yml"); - - await fsp.mkdir(dest, { recursive: true }); - await copyPackage(dest); - await ensureEngine(dest); - const indexFile = path.join(dest, "index.mjs"); - if (!fs.existsSync(indexFile)) { - throw new Error(`缺少插件入口:${indexFile}`); - } - - if (fs.existsSync(webPackage)) { - await writePatch(webPatch, fileUriInsert(toFileUri(indexFile))); - if (fs.existsSync(homePatch)) { - const homeRaw = await fsp.readFile(homePatch, "utf8"); - if (hasBridge(homeRaw)) await removePatch(homePatch); - } - console.log(`已写入 ${webPatch}`); - console.log(`插件已复制到 ${dest}`); - console.log("请自己运行:npx @deepseek-ai/dsh web"); - return { dest, patch: webPatch }; - } - - await fsp.mkdir(dshHome, { recursive: true }); - await writePatch(homePatch, fileUriInsert(toFileUri(indexFile))); - console.log(`DSH web profile 还不存在,已写入 ${homePatch}`); - console.log(`插件已复制到 ${dest}`); - console.log("请自己运行:npx @deepseek-ai/dsh web"); - return { dest, patch: homePatch }; -} - -async function uninstall(opts) { - const dshHome = path.resolve(opts.dshHome); - const removed = []; - if (await removePatch(path.join(dshHome, "profiles", "web", "cordis.patch.yml"))) { - removed.push("web patch"); - } - if (await removePatch(path.join(dshHome, "cordis.patch.yml"))) { - removed.push("home patch"); - } - const dest = path.resolve(opts.pluginHome); - if (fs.existsSync(dest)) { - await fsp.rm(dest, { recursive: true, force: true }); - removed.push(dest); - } - console.log(removed.length ? `已移除:${removed.join("、")}` : "没有可移除的 beautiCode 插件接线。"); - return { removed }; -} - -export async function runCli(argv = process.argv.slice(2)) { - const dshHome = argValue(argv, "--dsh-home") || defaultDshHome(); - const pluginHome = - argValue(argv, "--plugin-home") || path.join(defaultDataRoot(), "plugin"); - const opts = { dshHome, pluginHome }; - if (argv.includes("--remove")) return uninstall(opts); - return install(opts); -} - -const launchedDirectly = - Boolean(process.argv[1]) && - pathToFileURL(path.resolve(process.argv[1])).href.toLowerCase() === - import.meta.url.toLowerCase(); -if (launchedDirectly) { - runCli().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - }); -} +#!/usr/bin/env node +/** + * One-line installer: npx beauticode-dsh + * + * Copies this package to a stable local folder and writes the DSH patch so + * `dsh web` loads beautiCode. Does not start DeepSeek Harness. + */ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +function findPackageRoot(startDir) { + let dir = startDir; + for (let i = 0; i < 5; i += 1) { + if ( + fs.existsSync(path.join(dir, "package.json")) && + fs.existsSync(path.join(dir, "index.mjs")) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return startDir; +} + +const here = findPackageRoot(path.dirname(fileURLToPath(import.meta.url))); +const pluginName = "beauticode-dsh"; +const bridgeId = "beauticode-bridge"; + +function argValue(argv, name) { + const idx = argv.indexOf(name); + if (idx === -1) return null; + const value = argv[idx + 1]; + return value && !value.startsWith("--") ? value : null; +} + +function defaultDataRoot() { + if (process.env.BEAUTICODE_DATA_ROOT) return process.env.BEAUTICODE_DATA_ROOT; + if (process.env.LOCALAPPDATA) { + return path.join(process.env.LOCALAPPDATA, "beautiCode"); + } + return path.join(os.homedir(), ".beauticode"); +} + +function defaultDshHome() { + return process.env.DSH_HOME || path.join(os.homedir(), ".dsh"); +} + +function defaultPluginHome(dshHome) { + return path.join(path.resolve(dshHome), "plugins", pluginName); +} + +function legacyDefaultPluginHome() { + return path.join(defaultDataRoot(), "plugin"); +} + +function toFileUri(filePath) { + const full = path.resolve(filePath).replaceAll("\\", "/"); + if (/^[A-Za-z]:/.test(full)) return `file:///${full}`; + return pathToFileURL(full).href; +} + +function fileUriInsert(uri) { + return [ + "# beauticode-bridge (installer)", + "- insert:", + " - id: beauticode-bridge", + ` name: '${uri}'`, + " inject: [webServer]", + "", + ].join("\n"); +} + +function packageInsert() { + return [ + "# beauticode-bridge (installer)", + "- insert:", + " - id: beauticode-bridge", + ` name: '${pluginName}'`, + " inject: [webServer]", + "", + ].join("\n"); +} + +function hasBridge(text) { + return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text); +} + +function stripBridge(text) { + const lines = text.split(/\r?\n/); + const removed = new Array(lines.length).fill(false); + const indentOf = (line) => line.match(/^[ \t]*/)?.[0].length ?? 0; + + for (let i = 0; i < lines.length; i += 1) { + const insert = lines[i].match(/^([ \t]*)-\s*insert:\s*(?:#.*)?$/); + if (!insert) continue; + const insertIndent = insert[1].length; + let blockEnd = i + 1; + while (blockEnd < lines.length) { + const line = lines[blockEnd]; + if (line.trim() && indentOf(line) <= insertIndent) break; + blockEnd += 1; + } + + let foundBridge = false; + for (let j = i + 1; j < blockEnd; ) { + const bridge = lines[j].match( + /^([ \t]*)-\s*id:\s*beauticode-bridge\s*(?:#.*)?$/, + ); + if (!bridge || bridge[1].length <= insertIndent) { + j += 1; + continue; + } + foundBridge = true; + const itemIndent = bridge[1].length; + let itemEnd = j + 1; + while (itemEnd < blockEnd) { + const line = lines[itemEnd]; + if (line.trim() && indentOf(line) <= itemIndent) break; + itemEnd += 1; + } + for (let k = j; k < itemEnd; k += 1) removed[k] = true; + j = itemEnd; + } + + if (!foundBridge) continue; + const marker = `${insert[1]}# beauticode-bridge (installer)`; + if (i > 0 && lines[i - 1].trimEnd() === marker) removed[i - 1] = true; + const hasSibling = lines + .slice(i + 1, blockEnd) + .some( + (line, offset) => + !removed[i + 1 + offset] && + line.trim() !== "" && + !line.trimStart().startsWith("#"), + ); + if (!hasSibling) { + for (let k = i; k < blockEnd; k += 1) removed[k] = true; + } + i = blockEnd - 1; + } + + return lines.filter((_, index) => !removed[index]).join("\n"); +} + +function overlayPayload(text) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .join("\n"); +} + +function isEmptyOverlay(text) { + const payload = overlayPayload(text); + return payload === "" || payload === "[]"; +} + +// DSH seeds overlays as `# comment\n[]`. `[]` is already a complete YAML +// document, so appending `- insert:` makes js-yaml throw. +function keptOverlay(text) { + const withoutFlowEmpty = text + .split(/\r?\n/) + .filter((line) => line.trim() !== "[]") + .join("\n"); + if (isEmptyOverlay(withoutFlowEmpty)) return ""; + return withoutFlowEmpty.trim(); +} + +function withTrailingNewline(text) { + return text.endsWith("\n") ? text : `${text}\n`; +} + +async function writePatch(filePath, body) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + const insert = withTrailingNewline(body); + if (!fs.existsSync(filePath)) { + await fsp.writeFile(filePath, insert, "utf8"); + return; + } + const raw = await fsp.readFile(filePath, "utf8"); + const remainder = hasBridge(raw) ? stripBridge(raw) : raw; + const kept = keptOverlay(remainder); + const next = kept ? `${kept}\n\n${insert}` : insert; + await fsp.writeFile(filePath, withTrailingNewline(next), "utf8"); +} + +async function removePatch(filePath) { + if (!fs.existsSync(filePath)) return false; + const raw = await fsp.readFile(filePath, "utf8"); + if (!hasBridge(raw)) return false; + const kept = keptOverlay(stripBridge(raw)); + if (!kept) { + await fsp.writeFile( + filePath, + "# Your patch layer for this dsh profile.\n[]\n", + "utf8", + ); + return true; + } + await fsp.writeFile(filePath, withTrailingNewline(kept), "utf8"); + return true; +} + +function shouldCopy(source) { + const relative = path.relative(here, source); + if (relative.startsWith("test") || relative.includes(`${path.sep}test${path.sep}`)) { + return false; + } + if (relative.endsWith(".test.mjs")) return false; + return true; +} + +async function sameInstalledVersion(dest) { + try { + const incoming = JSON.parse(await fsp.readFile(path.join(here, "package.json"), "utf8")); + const installed = JSON.parse(await fsp.readFile(path.join(dest, "package.json"), "utf8")); + return ( + incoming.name === installed.name && + incoming.version === installed.version && + fs.existsSync(path.join(dest, "index.mjs")) && + fs.existsSync(path.join(dest, "vendor", "adapter-dsh", "index.js")) + ); + } catch { + return false; + } +} + +async function copyPackage(dest) { + if (await sameInstalledVersion(dest)) return false; + await fsp.rm(dest, { recursive: true, force: true }); + await fsp.cp(here, dest, { + recursive: true, + filter: (source) => shouldCopy(source), + }); + return true; +} + +async function removeLegacyManagedPlugin(legacyHome, currentHome) { + if (!legacyHome) return false; + const legacy = path.resolve(legacyHome); + if (legacy === path.resolve(currentHome) || !fs.existsSync(legacy)) return false; + try { + const pkg = JSON.parse(await fsp.readFile(path.join(legacy, "package.json"), "utf8")); + if ( + ![pluginName, "@beauticode/dsh-plugin"].includes(pkg.name) || + !fs.existsSync(path.join(legacy, "index.mjs")) + ) { + return false; + } + } catch { + return false; + } + await fsp.rm(legacy, { recursive: true, force: true }); + return true; +} + +async function ensureEngine(dest) { + const vendor = path.join(dest, "vendor", "adapter-dsh", "index.js"); + if (fs.existsSync(vendor)) return; + const packPath = path.resolve(here, "../../scripts/pack-dsh-plugin.mjs"); + if (!fs.existsSync(packPath)) { + throw new Error("插件包不完整:缺少本机导入引擎。请重新执行 npx beauticode-dsh。"); + } + const { stageEngineInto } = await import(pathToFileURL(packPath).href); + await stageEngineInto(dest); +} + +function pluginLinkPath(webProfile) { + return path.join(webProfile, "node_modules", pluginName); +} + +function legacyPluginLinkPath(webProfile) { + return path.join(webProfile, "node_modules", "@beauticode", "dsh-plugin"); +} + +function linkSpecFor(pluginHome) { + return `link:${path.resolve(pluginHome).replaceAll("\\", "/")}`; +} + +async function sameLinkTarget(link, dest) { + try { + const stat = await fsp.lstat(link); + if (stat.isSymbolicLink()) { + const target = await fsp.readlink(link); + return path.resolve(path.dirname(link), target) === path.resolve(dest); + } + if (stat.isDirectory()) { + return path.resolve(link) === path.resolve(dest); + } + } catch { + return false; + } + return false; +} + +async function linkPluginIntoProfile(webProfile, dest) { + const link = pluginLinkPath(webProfile); + const legacy = legacyPluginLinkPath(webProfile); + if (fs.existsSync(legacy)) { + await fsp.rm(legacy, { recursive: true, force: true }); + } + await fsp.mkdir(path.dirname(link), { recursive: true }); + if (fs.existsSync(link)) { + if (await sameLinkTarget(link, dest)) return; + await fsp.rm(link, { recursive: true, force: true }); + } + const type = process.platform === "win32" ? "junction" : "dir"; + await fsp.symlink(path.resolve(dest), link, type); +} + +async function ensureWebPackageDep(webPackage, pluginHome) { + const raw = await fsp.readFile(webPackage, "utf8"); + const json = JSON.parse(raw); + if (!json.dependencies || typeof json.dependencies !== "object" || Array.isArray(json.dependencies)) { + json.dependencies = {}; + } + const spec = linkSpecFor(pluginHome); + const hadLegacy = Object.prototype.hasOwnProperty.call( + json.dependencies, + "@beauticode/dsh-plugin", + ); + delete json.dependencies["@beauticode/dsh-plugin"]; + const current = json.dependencies[pluginName]; + if (current === spec && !hadLegacy) return; + json.dependencies[pluginName] = spec; + await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8"); +} + +async function removeWebPackageDep(webPackage) { + if (!fs.existsSync(webPackage)) return false; + const json = JSON.parse(await fsp.readFile(webPackage, "utf8")); + if (!json.dependencies || typeof json.dependencies !== "object") return false; + let changed = false; + for (const name of [pluginName, "@beauticode/dsh-plugin"]) { + if (Object.prototype.hasOwnProperty.call(json.dependencies, name)) { + delete json.dependencies[name]; + changed = true; + } + } + if (!changed) return false; + await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8"); + return true; +} + +async function install(opts) { + const dest = path.resolve(opts.pluginHome); + const dshHome = path.resolve(opts.dshHome); + const webProfile = path.join(dshHome, "profiles", "web"); + const webPatch = path.join(webProfile, "cordis.patch.yml"); + const webPackage = path.join(webProfile, "package.json"); + const homePatch = path.join(dshHome, "cordis.patch.yml"); + + await fsp.mkdir(dest, { recursive: true }); + const copied = await copyPackage(dest); + await ensureEngine(dest); + const indexFile = path.join(dest, "index.mjs"); + if (!fs.existsSync(indexFile)) { + throw new Error(`缺少插件入口:${indexFile}`); + } + + if (fs.existsSync(webPackage)) { + await linkPluginIntoProfile(webProfile, dest); + await ensureWebPackageDep(webPackage, dest); + await writePatch(webPatch, packageInsert()); + if (fs.existsSync(homePatch)) { + const homeRaw = await fsp.readFile(homePatch, "utf8"); + if (hasBridge(homeRaw)) await removePatch(homePatch); + } + const migrated = await removeLegacyManagedPlugin(opts.legacyPluginHome, dest); + console.log(`已写入 ${webPatch}`); + console.log(copied ? `插件已安装到 ${dest}` : `已复用已安装的插件 ${dest}`); + if (migrated) console.log("已迁移 1.0.5 的旧插件目录,已保留主题数据。"); + console.log("请自己运行:npx @deepseek-ai/dsh web"); + return { dest, patch: webPatch }; + } + + await fsp.mkdir(dshHome, { recursive: true }); + await writePatch(homePatch, fileUriInsert(toFileUri(indexFile))); + const migrated = await removeLegacyManagedPlugin(opts.legacyPluginHome, dest); + console.log(`DSH web profile 还不存在,已写入 ${homePatch}`); + console.log(copied ? `插件已安装到 ${dest}` : `已复用已安装的插件 ${dest}`); + if (migrated) console.log("已迁移 1.0.5 的旧插件目录,已保留主题数据。"); + console.log("请自己运行:npx @deepseek-ai/dsh web"); + return { dest, patch: homePatch }; +} + +async function uninstall(opts) { + const dshHome = path.resolve(opts.dshHome); + const webProfile = path.join(dshHome, "profiles", "web"); + const removed = []; + if (await removePatch(path.join(webProfile, "cordis.patch.yml"))) { + removed.push("web patch"); + } + if (await removePatch(path.join(dshHome, "cordis.patch.yml"))) { + removed.push("home patch"); + } + if (await removeWebPackageDep(path.join(webProfile, "package.json"))) { + removed.push("web package.json"); + } + for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) { + if (fs.existsSync(link)) { + await fsp.rm(link, { recursive: true, force: true }); + removed.push(link); + } + } + const dest = path.resolve(opts.pluginHome); + if (fs.existsSync(dest)) { + await fsp.rm(dest, { recursive: true, force: true }); + removed.push(dest); + } + console.log(removed.length ? `已移除:${removed.join("、")}` : "没有可移除的 beautiCode 插件接线。"); + return { removed }; +} + +export async function runCli(argv = process.argv.slice(2)) { + const dshHome = argValue(argv, "--dsh-home") || defaultDshHome(); + const requestedPluginHome = argValue(argv, "--plugin-home"); + const pluginHome = requestedPluginHome || defaultPluginHome(dshHome); + const opts = { + dshHome, + pluginHome, + legacyPluginHome: requestedPluginHome ? null : legacyDefaultPluginHome(), + }; + if (argv.includes("--remove")) return uninstall(opts); + return install(opts); +} + +const launchedDirectly = + Boolean(process.argv[1]) && + pathToFileURL(path.resolve(process.argv[1])).href.toLowerCase() === + import.meta.url.toLowerCase(); +if (launchedDirectly) { + runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/integrations/deepseek-harness/client.js b/integrations/deepseek-harness/client.js index 359332f..bf574af 100644 --- a/integrations/deepseek-harness/client.js +++ b/integrations/deepseek-harness/client.js @@ -1,134 +1,167 @@ -(() => { - "use strict"; - if (window.__beauticodeBridgeLoaded) return; - window.__beauticodeBridgeLoaded = true; - window.__beauticodeBridgeVersion = 4; - +(() => { + "use strict"; + if (window.__beauticodeBridgeLoaded) return; + window.__beauticodeBridgeLoaded = true; + window.__beauticodeBridgeVersion = 4; + const clientId = globalThis.crypto?.randomUUID?.() || `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const desiredModes = { fish: false, muted: true, tone: "auto" }; + // The DSH host owns a hard 10-second verification boundary. Keep the browser + // transaction inside it so failures remain phase-specific instead of turning + // into a generic host timeout. + const CLIENT_APPLY_DEADLINE_MS = 8_000; + const IMAGE_LOAD_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS; + const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000; + const IMAGE_MAX_ATTEMPTS = 2; + const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS; + const VIDEO_PROBE_TIMEOUT_MS = 2_000; + const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS; + const FRAME_FALLBACK_MS = 120; + const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03; + const VIDEO_STABLE_FRAMES = 3; + const VIDEO_STABLE_PROGRESS_SEC = 0.18; + const CROSSFADE_MS = 180; let activePayload = null; + let committedPayload = null; + let renderPhase = "idle"; let playbackBlocked = false; + let currentSlot = null; + let applyController = null; const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null; - let themeSyncQueued = false; - - const style = document.createElement("style"); - style.dataset.beauticodeBridge = "true"; - style.textContent = ` -html[data-bc-active="true"],html[data-bc-active="true"] body{background:transparent!important} -html[data-bc-active="true"] body{ - --dsw-alias-bg-base:rgba(17,20,27,.10); - --dsw-alias-bg-layer-1:rgba(26,30,39,.28); - --dsw-alias-bg-layer-2:rgba(35,40,51,.32); - --dsw-alias-bg-overlay:rgba(17,20,27,.12); - --dsw-specific-sidebar-fill:rgba(23,27,35,.28); -} -html[data-bc-resolved-tone="light"][data-bc-active="true"] body{ - --dsw-alias-bg-base:rgba(248,250,252,.12); - --dsw-alias-bg-layer-1:rgba(255,255,255,.28); - --dsw-alias-bg-layer-2:rgba(248,250,252,.32); - --dsw-alias-bg-overlay:rgba(255,255,255,.14); - --dsw-specific-sidebar-fill:rgba(255,255,255,.28); -} -html[data-bc-active="true"]:has(#root [data-phase="active"]) body, -html[data-bc-active="true"]:has(#root [data-phase="settling"]) body{ - --dsw-alias-bg-base:rgba(17,20,27,.42); - --dsw-alias-bg-layer-1:rgba(26,30,39,.72); - --dsw-alias-bg-layer-2:rgba(35,40,51,.80); - --dsw-alias-bg-overlay:rgba(17,20,27,.86); - --dsw-specific-sidebar-fill:rgba(23,27,35,.78); -} -html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) body, -html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) body{ - --dsw-alias-bg-base:rgba(248,250,252,.48); - --dsw-alias-bg-layer-1:rgba(255,255,255,.74); - --dsw-alias-bg-layer-2:rgba(248,250,252,.82); - --dsw-alias-bg-overlay:rgba(255,255,255,.86); - --dsw-specific-sidebar-fill:rgba(255,255,255,.78); -} -#beauticode-bg-stage{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#11141b} -#beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:2;background:transparent;pointer-events:none} -html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc} -html[data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after, -html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(0,0,0,.42)} -html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after, -html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(255,255,255,.22)} -html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important} -#beauticode-bg-stage img,#beauticode-bg-stage video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none} -#beauticode-bg-stage img{z-index:0} -#beauticode-bg-stage video{z-index:1} -html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important} -html[data-bc-active="true"] [class*="_fade"]{display:none!important} -html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} -`; - document.head.append(style); - - function dshAppearance() { - const body = document.body; - if (body?.hasAttribute("data-ds-dark-theme")) return "dark"; - const scheme = document.documentElement.style.colorScheme; - if (scheme === "dark" || scheme === "light") return scheme; - return systemDarkMedia?.matches ? "dark" : "light"; - } - - function resolvedTone() { - return dshAppearance(); - } - - function isDshThemeSynced(tone = resolvedTone()) { - return document.documentElement.dataset.bcResolvedTone === tone; - } - - function syncDshTheme() { - const tone = resolvedTone(); - document.documentElement.dataset.bcResolvedTone = tone; - return isDshThemeSynced(tone); - } - - function scheduleDshThemeSync() { - if (themeSyncQueued) return; - themeSyncQueued = true; - queueMicrotask(() => { - themeSyncQueued = false; - syncDshTheme(); - }); - } - - const themeObserver = new MutationObserver(scheduleDshThemeSync); - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["style"], - }); - if (document.body) { - themeObserver.observe(document.body, { - attributes: true, - attributeFilter: ["data-ds-dark-theme"], - }); - } + const reducedMotionMedia = + globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null; + let themeSyncQueued = false; + + const style = document.createElement("style"); + style.dataset.beauticodeBridge = "true"; + style.textContent = ` +html[data-bc-active="true"],html[data-bc-active="true"] body{background:transparent!important} +html[data-bc-active="true"] body{ + --dsw-alias-bg-base:rgba(17,20,27,.10); + --dsw-alias-bg-layer-1:rgba(26,30,39,.28); + --dsw-alias-bg-layer-2:rgba(35,40,51,.32); + --dsw-alias-bg-overlay:rgba(17,20,27,.12); + --dsw-specific-sidebar-fill:rgba(23,27,35,.28); +} +html[data-bc-resolved-tone="light"][data-bc-active="true"] body{ + --dsw-alias-bg-base:rgba(248,250,252,.12); + --dsw-alias-bg-layer-1:rgba(255,255,255,.28); + --dsw-alias-bg-layer-2:rgba(248,250,252,.32); + --dsw-alias-bg-overlay:rgba(255,255,255,.14); + --dsw-specific-sidebar-fill:rgba(255,255,255,.28); +} +html[data-bc-active="true"]:has(#root [data-phase="active"]) body, +html[data-bc-active="true"]:has(#root [data-phase="settling"]) body{ + --dsw-alias-bg-base:rgba(17,20,27,.42); + --dsw-alias-bg-layer-1:rgba(26,30,39,.72); + --dsw-alias-bg-layer-2:rgba(35,40,51,.80); + --dsw-alias-bg-overlay:rgba(17,20,27,.86); + --dsw-specific-sidebar-fill:rgba(23,27,35,.78); +} +html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) body, +html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) body{ + --dsw-alias-bg-base:rgba(248,250,252,.48); + --dsw-alias-bg-layer-1:rgba(255,255,255,.74); + --dsw-alias-bg-layer-2:rgba(248,250,252,.82); + --dsw-alias-bg-overlay:rgba(255,255,255,.86); + --dsw-specific-sidebar-fill:rgba(255,255,255,.78); +} +#beauticode-bg-stage{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#11141b} +#beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:3;background:transparent;pointer-events:none} +html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc} +html[data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after, +html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(0,0,0,.42)} +html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after, +html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(255,255,255,.22)} +html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important} +#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity} +#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1} +#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1} +#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1} +#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0} +#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1} +#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease} +#beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1} +/* Keep cold candidate video paintable. Chromium may defer decoding media that + is nearly transparent, which deadlocks the first-frame gate. The poster + covers it until data-bc-video-ready is committed. */ +#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1} +#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0} +#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1} +@media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}} +html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important} +html[data-bc-active="true"] [class*="_fade"]{display:none!important} +html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} +`; + document.head.append(style); + + function dshAppearance() { + const body = document.body; + if (body?.hasAttribute("data-ds-dark-theme")) return "dark"; + const scheme = document.documentElement.style.colorScheme; + if (scheme === "dark" || scheme === "light") return scheme; + return systemDarkMedia?.matches ? "dark" : "light"; + } + + function resolvedTone() { + return dshAppearance(); + } + + function isDshThemeSynced(tone = resolvedTone()) { + return document.documentElement.dataset.bcResolvedTone === tone; + } + + function syncDshTheme() { + const tone = resolvedTone(); + document.documentElement.dataset.bcResolvedTone = tone; + return isDshThemeSynced(tone); + } + + function scheduleDshThemeSync() { + if (themeSyncQueued) return; + themeSyncQueued = true; + queueMicrotask(() => { + themeSyncQueued = false; + syncDshTheme(); + }); + } + + const themeObserver = new MutationObserver(scheduleDshThemeSync); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"], + }); + if (document.body) { + themeObserver.observe(document.body, { + attributes: true, + attributeFilter: ["data-ds-dark-theme"], + }); + } systemDarkMedia?.addEventListener("change", () => { if (desiredModes.tone === "auto") { syncDshTheme(); - void acknowledgeMode(); + void acknowledgeMode().catch(() => {}); } }); - syncDshTheme(); - - function stage() { - let node = document.getElementById("beauticode-bg-stage"); - if (!node) { - node = document.createElement("div"); - node.id = "beauticode-bg-stage"; - document.body.prepend(node); - } - return node; - } - - /** - * Fail closed on DSH DOM drift: the injected CSS depends on `#root`. - * If the shell no longer exposes it, report a clear error through the - * render ack instead of silently painting a broken page. - */ + syncDshTheme(); + + function stage() { + let node = document.getElementById("beauticode-bg-stage"); + if (!node) { + node = document.createElement("div"); + node.id = "beauticode-bg-stage"; + document.body.prepend(node); + } + return node; + } + + /** + * Fail closed on DSH DOM drift: the injected CSS depends on `#root`. + * If the shell no longer exposes it, report a clear error through the + * render ack instead of silently painting a broken page. + */ function dshStructureIssue() { if (!document.getElementById("root")) { return "DSH 页面结构不兼容:未找到 #root。"; @@ -136,235 +169,1283 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important; return null; } + function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) { + if (!dshStructureIssue()) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + const startedAt = performance.now(); + const finish = (error = null) => { + if (settled) return; + settled = true; + clearInterval(timer); + signal?.removeEventListener?.("abort", aborted); + if (error) reject(error); + else resolve(); + }; + const aborted = () => finish(abortError()); + const timer = setInterval(() => { + const issue = dshStructureIssue(); + if (!issue) { + finish(); + } else if (performance.now() - startedAt >= timeoutMs) { + finish(new Error(issue)); + } + }, 50); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + function mountedCurrentSlot() { + const node = document.getElementById("beauticode-bg-stage"); + if ( + !currentSlot || + currentSlot.isConnected !== true || + currentSlot.parentElement !== node || + currentSlot.dataset.bcRole !== "current" + ) { + currentSlot = null; + committedPayload = null; + return null; + } + return currentSlot; + } + function activeVideo() { - return document.querySelector("#beauticode-bg-stage video"); - } - - async function postAck(body) { - await fetch("/__beauticode/ack", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ clientId, ...body }), - }).catch(() => {}); - } - - function playbackSnapshot(video) { - if (!(video instanceof HTMLVideoElement)) return null; - return { - currentTime: Number.isFinite(video.currentTime) ? Math.max(0, video.currentTime) : 0, - duration: Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0, - hasVideo: true, - muted: video.muted, - paused: video.paused, - blocked: playbackBlocked, - }; + const video = mountedCurrentSlot()?.querySelector?.("video") ?? null; + return video instanceof HTMLVideoElement ? video : null; } - async function acknowledgeRender(payload, ok, visible, error = null) { - await postAck({ - kind: "render", - generation: payload.generation, - media: payload.media, - ok, - visible, - error, - playback: payload.media === "video" ? playbackSnapshot(activeVideo()) : null, - }); + function pendingVideo() { + const node = document.getElementById("beauticode-bg-stage"); + const video = node?.querySelector?.( + '.beauticode-media-slot[data-bc-role="candidate"] video', + ); + return video instanceof HTMLVideoElement ? video : null; + } + + async function postAck(body) { + await fetch("/__beauticode/ack", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientId, ...body }), + }).catch(() => {}); + } + + function playbackSnapshot(video) { + if (!(video instanceof HTMLVideoElement)) return null; + return { + currentTime: Number.isFinite(video.currentTime) ? Math.max(0, video.currentTime) : 0, + duration: Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0, + hasVideo: true, + muted: video.muted, + paused: video.paused, + blocked: playbackBlocked, + }; + } + + async function acknowledgeRender(payload, ok, visible, error = null) { + if (activePayload === payload) { + renderPhase = ok ? "ready" : "failed"; + } + await postAck({ + kind: "render", + generation: payload.generation, + media: payload.media, + ok, + visible, + error, + playback: + ok && committedPayload === payload && payload.media === "video" + ? playbackSnapshot(activeVideo()) + : null, + }); + } + + async function acknowledgeMode() { + const video = activeVideo(); + const effectiveTone = resolvedTone(); + await postAck({ + kind: "mode", + fish: document.documentElement.dataset.bcFish === "true", + muted: video instanceof HTMLVideoElement ? video.muted : desiredModes.muted, + tone: document.documentElement.dataset.bcTone || "dark", + resolvedTone: effectiveTone, + themeSynced: isDshThemeSynced(effectiveTone), + blocked: playbackBlocked, + }); + } + + function abortError() { + try { + return new DOMException("Background apply superseded", "AbortError"); + } catch { + const error = new Error("Background apply superseded"); + error.name = "AbortError"; + return error; + } } - async function acknowledgeMode() { - const video = activeVideo(); - const effectiveTone = resolvedTone(); - await postAck({ - kind: "mode", - fish: document.documentElement.dataset.bcFish === "true", - muted: video instanceof HTMLVideoElement ? video.muted : desiredModes.muted, - tone: document.documentElement.dataset.bcTone || "dark", - resolvedTone: effectiveTone, - themeSynced: isDshThemeSynced(effectiveTone), - blocked: playbackBlocked, - }); + function isAbortError(error) { + return error?.name === "AbortError"; + } + + function throwIfAborted(signal) { + if (signal?.aborted) throw abortError(); } - function loadImage(url) { - const image = new Image(); - image.alt = ""; - image.crossOrigin = "anonymous"; - image.decoding = "async"; + function releaseImage(image, { remove = false } = {}) { + if (!image) return; + image.onload = null; + image.onerror = null; + try { + image.removeAttribute?.("src"); + } catch { + image.src = ""; + } + if (remove) image.remove?.(); + } + + function imageAttemptUrl(url, attempt) { + if (attempt === 0) return url; + try { + const retryUrl = new URL(url, globalThis.location?.href); + retryUrl.searchParams.set( + "bcImageRetry", + `${clientId}-${attempt}-${Date.now().toString(36)}`, + ); + return retryUrl.href; + } catch { + return url; + } + } + + function waitForImageAttempt(image, url, signal, timeoutMs) { return new Promise((resolve, reject) => { - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("图片加载失败")); + let settled = false; + let decodeStarted = false; + let decodeFallback = null; + const cleanup = () => { + clearTimeout(timer); + if (decodeFallback) clearTimeout(decodeFallback); + signal?.removeEventListener?.("abort", aborted); + image.onload = null; + image.onerror = null; + }; + const finish = (error = null) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + const acceptLoaded = () => { + if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) { + if (image.complete) finish(new Error("图片尺寸无效")); + return; + } + if (!decodeStarted && typeof image.decode === "function") { + decodeStarted = true; + // decode() is a second readiness signal, but the per-attempt timer + // remains authoritative. A loaded image is still accepted after a + // short decode grace period if Chromium leaves decode() pending. + decodeFallback = setTimeout(() => { + if ( + image.isConnected === true && + image.complete && + image.naturalWidth > 0 && + image.naturalHeight > 0 + ) { + finish(); + } else { + finish(new Error("图片解码未完成")); + } + }, 750); + let decodePromise; + try { + decodePromise = image.decode(); + } catch { + finish(new Error("图片解码失败")); + return; + } + Promise.resolve(decodePromise).then( + () => finish(), + () => finish(new Error("图片解码失败")), + ); + return; + } + finish(); + }; + const failed = () => finish(new Error("图片请求失败")); + const aborted = () => finish(abortError()); + const timer = setTimeout( + () => finish(new Error("等待图片请求或解码超时")), + timeoutMs, + ); + image.onload = acceptLoaded; + image.onerror = failed; + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) { + aborted(); + return; + } image.src = url; + if (image.complete) queueMicrotask(acceptLoaded); }); } - function waitForVideo(video) { - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve(); + function waitForRetryWindow(signal, timeoutMs) { return new Promise((resolve, reject) => { - const done = () => { - cleanup(); - resolve(); + let settled = false; + const finish = (error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener?.("abort", aborted); + if (error) reject(error); + else resolve(); + }; + const aborted = () => finish(abortError()); + const timer = setTimeout(() => finish(), timeoutMs); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) { + const deadline = performance.now() + timeoutMs; + const attempts = []; + let lastError = null; + + const startAttempt = (attempt) => { + throwIfAborted(signal); + const remainingMs = deadline - performance.now(); + if (remainingMs <= 0) throw new Error("等待图片请求或解码超时"); + const controller = new AbortController(); + const parentAborted = () => controller.abort(); + signal?.addEventListener?.("abort", parentAborted, { once: true }); + const image = new Image(); + image.alt = ""; + image.decoding = "async"; + image.className = "beauticode-media-poster"; + slot.prepend(image); + const promise = waitForImageAttempt( + image, + imageAttemptUrl(url, attempt), + controller.signal, + Math.max(1, remainingMs), + ) + .then(() => image) + .finally(() => signal?.removeEventListener?.("abort", parentAborted)); + const record = { controller, image, promise }; + attempts.push(record); + return record; + }; + + const cleanupAttempts = (winner = null) => { + for (const attempt of attempts) { + if (attempt.image === winner) continue; + attempt.controller.abort(); + releaseImage(attempt.image, { remove: true }); + } + }; + + try { + const first = startAttempt(0); + const firstWindow = await Promise.race([ + first.promise.then( + (image) => ({ image }), + (error) => ({ error }), + ), + waitForRetryWindow( + signal, + Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())), + ).then(() => ({ retry: true })), + ]); + if (firstWindow.image) { + cleanupAttempts(firstWindow.image); + return firstWindow.image; + } + if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError(); + lastError = firstWindow.error ?? lastError; + + // Keep the original request alive. The cache-busted retry runs beside it + // so a merely slow cold decode is never destroyed to recover a hung one. + if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1); + let winner; + try { + winner = await Promise.any(attempts.map((attempt) => attempt.promise)); + } catch (error) { + const errors = Array.isArray(error?.errors) ? error.errors : []; + lastError = errors.at(-1) ?? lastError ?? error; + throw lastError; + } + cleanupAttempts(winner); + return winner; + } catch (error) { + cleanupAttempts(); + if (isAbortError(error) || signal?.aborted) throw abortError(); + lastError = error ?? lastError; + const visibility = document.visibilityState || "unknown"; + const online = globalThis.navigator?.onLine === false ? "offline" : "online"; + const phase = lastError?.message || "unknown"; + const summary = + phase === "图片请求失败" + ? "图片加载失败" + : phase.includes("超时") + ? "等待图片加载超时" + : "图片校验失败"; + throw new Error( + `${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`, + ); + } + } + + function safeOrigin(value) { + try { + return new URL(value, globalThis.location?.href).origin; + } catch { + return "unknown"; + } + } + + function videoRequestContext(url) { + return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`; + } + + async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) { + throwIfAborted(signal); + const controller = new AbortController(); + const parentAborted = () => controller.abort(); + signal?.addEventListener?.("abort", parentAborted, { once: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, Math.max(1, timeoutMs)); + try { + const response = await fetch(url, { + method: "GET", + headers: { Range: "bytes=0-1" }, + cache: "no-store", + credentials: "omit", + mode: "cors", + signal: controller.signal, + }); + if (response.status !== 206) { + if (response.body) { + await response.body.cancel().catch(() => {}); + } + throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`); + } + const bytes = await response.arrayBuffer(); + if (bytes.byteLength !== 2) { + throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`); + } + } catch (error) { + if (signal?.aborted) throw abortError(); + const context = videoRequestContext(url); + if (timedOut || isAbortError(error)) { + throw new Error(`视频媒体 Range 探针超时;${context}`); + } + const detail = error instanceof Error ? error.message : String(error); + if (error instanceof TypeError) { + throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`); + } + throw new Error(`${detail};${context}`); + } finally { + clearTimeout(timer); + signal?.removeEventListener?.("abort", parentAborted); + } + } + + function describeVideoState(video, phase) { + const mediaErrorNames = { + 1: "MEDIA_ERR_ABORTED", + 2: "MEDIA_ERR_NETWORK", + 3: "MEDIA_ERR_DECODE", + 4: "MEDIA_ERR_SRC_NOT_SUPPORTED", + }; + const code = Number(video?.error?.code) || 0; + const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none"; + const src = video?.currentSrc || video?.src || ""; + return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)}`; + } + + function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) { + const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2; + if (video.readyState >= frameReadyState) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + const done = () => finish(); + const check = () => { + if (video.readyState >= frameReadyState) done(); }; const failed = () => { + finish(new Error(describeVideoState(video, "MP4 加载或解码失败"))); + }; + const aborted = () => { + finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止"))); + }; + const timer = setTimeout(() => { + finish(new Error(describeVideoState(video, "等待视频首帧超时"))); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + video.removeEventListener("loadeddata", check); + video.removeEventListener("canplay", done); + video.removeEventListener("error", failed); + video.removeEventListener("abort", aborted); + signal?.removeEventListener?.("abort", aborted); + }; + const finish = (error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + video.addEventListener("loadeddata", check); + video.addEventListener("canplay", done, { once: true }); + video.addEventListener("error", failed, { once: true }); + video.addEventListener("abort", aborted, { once: true }); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + function waitForSeek(video, signal, timeoutMs) { + if (!video.seeking) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + video.removeEventListener("seeked", done); + video.removeEventListener("error", failed); + signal?.removeEventListener?.("abort", aborted); + }; + const finish = (error) => { + if (settled) return; + settled = true; cleanup(); - reject(new Error("MP4 加载或解码失败")); + if (error) reject(error); + else resolve(); }; + const done = () => finish(); + const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败"))); + const aborted = () => finish(abortError()); + const timer = setTimeout( + () => finish(new Error(describeVideoState(video, "等待视频跳转超时"))), + timeoutMs, + ); + video.addEventListener("seeked", done, { once: true }); + video.addEventListener("error", failed, { once: true }); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + function waitForPresentedFrame(video, signal, timeoutMs) { + return new Promise((resolve, reject) => { + const startedAt = performance.now(); + const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + let frameSeen = false; + let playingSeen = + !video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + let settled = false; + let frameRequest = null; + const hasFrameCallback = typeof video.requestVideoFrameCallback === "function"; + const cleanup = () => { - video.removeEventListener("loadeddata", done); + clearInterval(timer); + video.removeEventListener("playing", onPlaying); + video.removeEventListener("timeupdate", check); + video.removeEventListener("loadeddata", check); + video.removeEventListener("canplay", check); video.removeEventListener("error", failed); + video.removeEventListener("abort", aborted); + signal?.removeEventListener?.("abort", aborted); + if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(frameRequest); + } + }; + const finish = (error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); }; - video.addEventListener("loadeddata", done, { once: true }); + const failed = () => { + finish(new Error(describeVideoState(video, "视频解码器报告失败"))); + }; + const aborted = () => { + finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止"))); + }; + const check = () => { + if (settled) return; + if (video.error) { + failed(); + return; + } + if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + playingSeen = true; + } + const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime; + const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC; + if ( + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + !video.paused && + playingSeen && + (frameSeen || progressed) + ) { + finish(); + return; + } + if (performance.now() - startedAt >= timeoutMs) { + finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现"))); + } + }; + const onPlaying = () => { + playingSeen = true; + check(); + }; + const onFrame = () => { + frameSeen = true; + check(); + }; + const timer = setInterval(check, 80); + video.addEventListener("playing", onPlaying); + video.addEventListener("timeupdate", check); + video.addEventListener("loadeddata", check); + video.addEventListener("canplay", check); video.addEventListener("error", failed, { once: true }); + video.addEventListener("abort", aborted, { once: true }); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame); + if (signal?.aborted) aborted(); + check(); }); } - function seekVideo(video, value) { - const requested = Number(value); - const duration = Number(video.duration); - const safe = - Number.isFinite(requested) && - requested >= 0 && - (!Number.isFinite(duration) || duration <= 0 || requested < duration) - ? requested - : 0; + function waitForStablePlayback(video, signal, timeoutMs) { + return new Promise((resolve, reject) => { + const startedAt = performance.now(); + let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + let accumulatedProgress = 0; + let advancingSamples = 0; + let stableFrames = 0; + let lastFrameTime = null; + let playingSeen = + !video.paused && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + let settled = false; + let frameRequest = null; + const hasFrameCallback = typeof video.requestVideoFrameCallback === "function"; + + const cleanup = () => { + clearInterval(timer); + video.removeEventListener("playing", onPlaying); + video.removeEventListener("timeupdate", check); + video.removeEventListener("waiting", resetStableWindow); + video.removeEventListener("stalled", resetStableWindow); + video.removeEventListener("pause", resetStableWindow); + video.removeEventListener("seeking", resetStableWindow); + video.removeEventListener("error", failed); + video.removeEventListener("abort", aborted); + signal?.removeEventListener?.("abort", aborted); + if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") { + video.cancelVideoFrameCallback(frameRequest); + } + }; + const finish = (error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + const failed = () => { + finish(new Error(describeVideoState(video, "视频解码器报告失败"))); + }; + const aborted = () => { + finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止"))); + }; + const observeProgress = () => { + const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime; + let delta = current - lastTime; + if ( + delta < 0 && + Number.isFinite(video.duration) && + video.duration > 0 && + lastTime > video.duration - 1 && + current < 1 + ) { + delta = video.duration - lastTime + current; + } + if (delta > 0.003 && delta < 2) { + accumulatedProgress += delta; + advancingSamples += 1; + } + lastTime = current; + }; + const resetStableWindow = () => { + playingSeen = false; + stableFrames = 0; + advancingSamples = 0; + accumulatedProgress = 0; + lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime; + }; + const check = () => { + if (settled) return; + if (video.error) { + failed(); + return; + } + observeProgress(); + if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + playingSeen = true; + } + const framesReady = hasFrameCallback + ? stableFrames >= VIDEO_STABLE_FRAMES + : advancingSamples >= VIDEO_STABLE_FRAMES; + if ( + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + !video.paused && + playingSeen && + framesReady && + accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC + ) { + finish(); + return; + } + if (performance.now() - startedAt >= timeoutMs) { + finish(new Error(describeVideoState(video, "视频未在稳定窗口内输出首帧"))); + } + }; + const onPlaying = () => { + playingSeen = true; + check(); + }; + const onFrame = (_now, metadata) => { + if (settled) return; + const mediaTime = Number.isFinite(metadata?.mediaTime) + ? metadata.mediaTime + : video.currentTime; + if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) { + stableFrames += 1; + } else { + stableFrames = 0; + } + lastFrameTime = mediaTime; + check(); + if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame); + }; + const timer = setInterval(check, 80); + video.addEventListener("playing", onPlaying); + video.addEventListener("timeupdate", check); + video.addEventListener("waiting", resetStableWindow); + video.addEventListener("stalled", resetStableWindow); + video.addEventListener("pause", resetStableWindow); + video.addEventListener("seeking", resetStableWindow); + video.addEventListener("error", failed, { once: true }); + video.addEventListener("abort", aborted, { once: true }); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame); + if (signal?.aborted) aborted(); + check(); + }); + } + + function seekVideo(video, value) { + const requested = Number(value); + const duration = Number(video.duration); + const safe = + Number.isFinite(requested) && + requested >= 0 && + (!Number.isFinite(duration) || duration <= 0 || requested < duration) + ? requested + : 0; try { video.currentTime = safe; - } catch { + } catch { video.currentTime = 0; } + return safe; } - async function playWithPreference(video) { - playbackBlocked = false; - video.muted = desiredModes.muted; + async function playWithPreference(video, signal = null) { + throwIfAborted(signal); + const requestedMuted = desiredModes.muted; + let blocked = false; + video.muted = requestedMuted; try { await video.play(); } catch (error) { - if (desiredModes.muted) throw error; - playbackBlocked = true; + throwIfAborted(signal); + if (requestedMuted) throw error; + blocked = true; video.muted = true; await video.play(); } + throwIfAborted(signal); + video.dataset.bcPlaybackBlocked = blocked ? "true" : "false"; + return blocked; } - function syncGallery(payload) { - const on = payload?.atmosphere?.preset === "gallery"; - globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed"); + async function startCandidatePlayback(video, signal, timeoutMs) { + const playbackController = new AbortController(); + const parentAborted = () => playbackController.abort(); + signal?.addEventListener?.("abort", parentAborted, { once: true }); + let timer = null; + let abortListener = null; + const playback = playWithPreference(video, playbackController.signal); + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(describeVideoState(video, "等待视频开始播放超时"))); + playbackController.abort(); + }, timeoutMs); + abortListener = () => reject(abortError()); + playbackController.signal.addEventListener("abort", abortListener, { once: true }); + }); + if (signal?.aborted) parentAborted(); + try { + // The play() promise itself resolves only after playback has started, so + // a second playing-event waiter would create an orphaned promise race. + return await Promise.race([playback, deadline]); + } catch (error) { + playbackController.abort(); + try { + video.pause(); + } catch { + /* best effort: disposeSlot will release src as the final guard */ + } + throw error; + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener?.("abort", parentAborted); + if (abortListener) { + playbackController.signal.removeEventListener("abort", abortListener); + } + } } - async function applyBackground(payload) { - if (!Number.isSafeInteger(payload?.generation)) return; - activePayload = payload; + function disposeVideo(video) { + if (!(video instanceof HTMLVideoElement)) return; + try { + video.pause(); + } catch { + /* best effort */ + } + const objectUrl = video.dataset?.bcObjectUrl; + if ( + objectUrl && + typeof URL !== "undefined" && + typeof URL.revokeObjectURL === "function" + ) { + URL.revokeObjectURL(objectUrl); + } + try { + video.removeAttribute("src"); + video.srcObject = null; + video.load(); + } catch { + /* detached or already released */ + } + } + + function disposeSlot(slot) { + if (!slot) return; + for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video); + for (const image of slot.querySelectorAll?.("img") ?? []) { + releaseImage(image); + } + slot.remove?.(); + } + + function createSlot(payload, video = null) { + const slot = document.createElement("div"); + slot.className = "beauticode-media-slot"; + slot.dataset.bcRole = "candidate"; + slot.dataset.bcMedia = payload.media; + slot.dataset.bcGeneration = String(payload.generation); + slot.dataset.bcImageUrl = payload.imageUrl; + if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl; + if (payload.media === "video") { + const startAt = Number(payload.startAt); + slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0); + } + if (video) { + video.className = "beauticode-media-video"; + slot.append(video); + } + return slot; + } + + function slotMatchesPayload(slot, payload) { + if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) { + return false; + } + if (payload.media !== "video") { + if (slot.dataset.bcImageUrl !== payload.imageUrl) return false; + const image = slot.querySelector?.("img"); + return Boolean( + image?.isConnected === true && + image.complete && + image.naturalWidth > 0 && + image.naturalHeight > 0, + ); + } + if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false; + const video = slot.querySelector?.("video"); + if ( + !(video instanceof HTMLVideoElement) || + video.error || + video.ended || + video.paused || + video.seeking || + video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA + ) { + return false; + } + // A local re-import can keep the exact same video handle while producing a + // new poster token and resetting startAt. Reuse the live decoder; the + // caller updates the poster metadata and seeks in place when needed. + return true; + } + + function updateCommittedDom(payload) { document.documentElement.dataset.bcGeneration = String(payload.generation); + document.documentElement.removeAttribute("data-bc-pending-generation"); + if (payload.media === "clear") { + document.documentElement.removeAttribute("data-bc-active"); + document.documentElement.removeAttribute("data-bc-media"); + document.documentElement.removeAttribute("data-bc-video-ready"); + return; + } + document.documentElement.dataset.bcActive = "true"; + document.documentElement.dataset.bcMedia = payload.media; + if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true"; + else document.documentElement.removeAttribute("data-bc-video-ready"); + } + + function discardCandidates() { + const node = document.getElementById("beauticode-bg-stage"); + if (!node) return; + node.removeAttribute("data-bc-transitioning"); + node.removeAttribute("data-bc-empty"); + for (const slot of + node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) { + disposeSlot(slot); + } + } + + function attachCandidate(slot, payload) { + const node = stage(); + discardCandidates(); + const previous = mountedCurrentSlot(); + slot.dataset.bcRole = "candidate"; + node.append(slot); + if (!previous) { + node.dataset.bcEmpty = "true"; + document.documentElement.dataset.bcActive = "true"; + document.documentElement.dataset.bcMedia = + payload.media === "video" ? "video-pending" : payload.media; + document.documentElement.removeAttribute("data-bc-video-ready"); + } + } + + function nextFrame(signal) { + if (typeof requestAnimationFrame !== "function") return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + let request = null; + let timer = null; + const cleanup = () => { + if (timer) clearTimeout(timer); + if (request != null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(request); + } + signal?.removeEventListener?.("abort", aborted); + }; + const finish = (error = null) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + const aborted = () => { + finish(abortError()); + }; + request = requestAnimationFrame(() => { + request = null; + if (signal?.aborted) finish(abortError()); + else finish(); + }); + // Background or power-saved Chromium pages may suspend rAF entirely. + // A short timer keeps the transaction bounded without skipping aborts. + timer = setTimeout(() => finish(), FRAME_FALLBACK_MS); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + function waitForCrossfade(slot, signal) { + if (reducedMotionMedia?.matches) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + slot.removeEventListener("transitionend", ended); + signal?.removeEventListener?.("abort", aborted); + }; + const finish = (error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + const ended = (event) => { + if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish(); + }; + const aborted = () => finish(abortError()); + const timer = setTimeout(() => finish(), CROSSFADE_MS + 80); + slot.addEventListener("transitionend", ended); + signal?.addEventListener?.("abort", aborted, { once: true }); + if (signal?.aborted) aborted(); + }); + } + + async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) { + throwIfAborted(signal); + const node = stage(); + const previous = mountedCurrentSlot(); + const canAnimate = + previous && + reducedMotionMedia?.matches !== true && + document.visibilityState === "visible" && + remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250; + if (canAnimate) { + await nextFrame(signal); + await nextFrame(signal); + throwIfAborted(signal); + node.dataset.bcTransitioning = "true"; + await waitForCrossfade(previous, signal); + } + throwIfAborted(signal); + if (previous && previous !== slot) disposeSlot(previous); + slot.dataset.bcRole = "current"; + node.removeAttribute("data-bc-transitioning"); + node.removeAttribute("data-bc-empty"); + currentSlot = slot; + committedPayload = payload; + const video = activeVideo(); + playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true"; + updateCommittedDom(payload); syncGallery(payload); + } + + function restoreCommittedDom() { + const node = document.getElementById("beauticode-bg-stage"); + node?.removeAttribute("data-bc-transitioning"); + node?.removeAttribute("data-bc-empty"); + if (committedPayload && mountedCurrentSlot()) { + updateCommittedDom(committedPayload); + playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true"; + return; + } + node?.remove(); + document.documentElement.removeAttribute("data-bc-active"); + document.documentElement.removeAttribute("data-bc-media"); + document.documentElement.removeAttribute("data-bc-video-ready"); + } + + function syncGallery(payload) { + const on = payload?.atmosphere?.preset === "gallery"; + try { + const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed"); + if (sync && typeof sync.then === "function") void sync.catch(() => {}); + } catch { + /* Atmosphere is optional and must never invalidate a rendered background. */ + } + } + + async function applyBackground(payload, signal) { + throwIfAborted(signal); + const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS; + const remaining = () => Math.max(1, applyDeadline - performance.now()); if (payload.media === "clear") { desiredModes.fish = false; playbackBlocked = false; - document.documentElement.removeAttribute("data-bc-active"); - document.documentElement.removeAttribute("data-bc-media"); document.documentElement.removeAttribute("data-bc-fish"); + discardCandidates(); + disposeSlot(currentSlot); + currentSlot = null; + committedPayload = payload; document.getElementById("beauticode-bg-stage")?.remove(); + updateCommittedDom(payload); + syncGallery(payload); if (document.documentElement.dataset.bcGallery === "true") { document.documentElement.dataset.bcActive = "true"; } - await acknowledgeRender(payload, true, false); - await acknowledgeMode(); + await acknowledgeRender(payload, true, false); + await acknowledgeMode(); + return; + } + if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) { + await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效"); return; } - if (typeof payload.imageUrl !== "string") return; - const structureIssue = dshStructureIssue(); - if (structureIssue) { - await acknowledgeRender(payload, false, false, structureIssue); + let reusable = slotMatchesPayload(currentSlot, payload); + if (reusable && payload.media === "video") { + try { + const reusableVideo = activeVideo(); + const requestedStartAt = Number(payload.startAt); + const normalizedStartAt = + Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0; + const appliedStartAt = Number(currentSlot.dataset.bcStartAt); + if ( + !Number.isFinite(appliedStartAt) || + Math.abs(appliedStartAt - normalizedStartAt) >= 0.25 + ) { + const nextStartAt = seekVideo(reusableVideo, normalizedStartAt); + currentSlot.dataset.bcStartAt = String(nextStartAt); + await waitForSeek(reusableVideo, signal, remaining()); + await waitForVideo(reusableVideo, signal, remaining()); + await waitForPresentedFrame(reusableVideo, signal, remaining()); + } else { + await waitForStablePlayback(reusableVideo, signal, Math.min(750, remaining())); + } + } catch (error) { + if (isAbortError(error) || signal?.aborted) throw error; + reusable = false; + } + } + if (reusable) { + committedPayload = payload; + currentSlot.dataset.bcGeneration = String(payload.generation); + currentSlot.dataset.bcImageUrl = payload.imageUrl; + if (payload.videoUrl) currentSlot.dataset.bcVideoUrl = payload.videoUrl; + updateCommittedDom(payload); + syncGallery(payload); + await acknowledgeRender(payload, true, true); + await acknowledgeMode(); return; } + let candidate = null; + let candidateVideo = null; + let committed = false; try { - const image = await loadImage(payload.imageUrl); - if (activePayload !== payload) return; - const node = stage(); + await waitForDshStructure(signal, remaining()); if (payload.media === "image") { - playbackBlocked = false; - node.replaceChildren(image); - document.documentElement.dataset.bcActive = "true"; - document.documentElement.dataset.bcMedia = "image"; - const visible = image.naturalWidth > 0 && image.naturalHeight > 0; - await acknowledgeRender(payload, visible, visible, visible ? null : "图片尺寸无效"); + candidate = createSlot(payload); + attachCandidate(candidate, payload); + const image = await loadImage(candidate, payload.imageUrl, signal, remaining()); + throwIfAborted(signal); + if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效"); + await commitCandidate(payload, candidate, signal, remaining); + committed = true; + await acknowledgeRender(payload, true, true); await acknowledgeMode(); return; } - if (payload.media !== "video" || typeof payload.videoUrl !== "string") return; + if (payload.media !== "video" || typeof payload.videoUrl !== "string") { + throw new Error("视频载荷无效"); + } const video = document.createElement("video"); video.autoplay = true; video.loop = true; video.playsInline = true; video.preload = "auto"; - video.poster = payload.imageUrl; video.crossOrigin = "anonymous"; + // Set mute before src so Chromium may start the request immediately + // without waiting for an audible autoplay decision. + video.defaultMuted = true; + video.muted = desiredModes.muted; + video.setAttribute("muted", ""); + candidate = createSlot(payload, video); + attachCandidate(candidate, payload); + candidateVideo = video; video.src = payload.videoUrl; - node.replaceChildren(image, video); - await waitForVideo(video); - if (activePayload !== payload) return; - seekVideo(video, payload.startAt); - await playWithPreference(video); - document.documentElement.dataset.bcActive = "true"; - document.documentElement.dataset.bcMedia = "video"; + video.load(); + // Start the real media request before the diagnostic Range probe. During + // a cold Chromium/decoder start, serial probing used part of the same + // eight-second budget without advancing the frame that users can see. + const sourceProbe = probeVideoSource( + payload.videoUrl, + signal, + Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()), + ); + const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining()); + // play() is deliberately started alongside media readiness. Chromium is + // allowed to ignore preload=auto for hidden/background media; waiting for + // canplay before play() creates a circular stall at readyState=0. + await Promise.all([ + sourceProbe, + imageReady, + waitForVideo(video, signal, remaining()), + startCandidatePlayback(video, signal, remaining()), + ]); + throwIfAborted(signal); + const appliedStartAt = seekVideo(video, payload.startAt); + candidate.dataset.bcStartAt = String(appliedStartAt); + await waitForSeek(video, signal, remaining()); + await waitForVideo(video, signal, remaining()); + // Transaction success means one frame was actually presented. Longer + // three-frame stability remains useful for reusing an existing slot, but + // must not turn a healthy cold decoder into a false first-frame failure. + await waitForPresentedFrame(video, signal, remaining()); + throwIfAborted(signal); + candidate.dataset.bcVideoReady = "true"; + await commitCandidate(payload, candidate, signal, remaining); + committed = true; const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused; await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放"); await acknowledgeMode(); } catch (error) { - if (activePayload !== payload) return; + if (candidateVideo && candidateVideo.parentElement !== candidate) { + disposeVideo(candidateVideo); + candidateVideo.remove?.(); + } + if (candidate && !committed) disposeSlot(candidate); + if (isAbortError(error) || signal?.aborted || activePayload !== payload) return; + restoreCommittedDom(); await acknowledgeRender( payload, false, - false, + Boolean(currentSlot), error instanceof Error ? error.message : String(error), ); } } - async function applyModes(payload) { - if (typeof payload.fish === "boolean") desiredModes.fish = payload.fish; - if (typeof payload.muted === "boolean") desiredModes.muted = payload.muted; - if (["dark", "light", "auto"].includes(payload.tone)) desiredModes.tone = payload.tone; - document.documentElement.dataset.bcTone = desiredModes.tone; - syncDshTheme(); - if (desiredModes.fish && document.documentElement.dataset.bcActive === "true") { - document.documentElement.dataset.bcFish = "true"; + function scheduleBackground(payload) { + if (!Number.isSafeInteger(payload?.generation)) return; + // EventSource reconnects can replay the current frame while its first + // render is still in flight. Restarting the same generation would discard + // a healthy cold media request and move its deadline indefinitely. + if ( + applyController && + activePayload?.generation === payload.generation && + activePayload?.media === payload.media + ) { + return; + } + if ( + Number.isSafeInteger(activePayload?.generation) && + payload.generation < activePayload.generation + ) { + return; + } + applyController?.abort(); + discardCandidates(); + const controller = new AbortController(); + applyController = controller; + activePayload = payload; + renderPhase = "pending"; + document.documentElement.dataset.bcPendingGeneration = String(payload.generation); + void applyBackground(payload, controller.signal) + .catch(async (error) => { + if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return; + restoreCommittedDom(); + await acknowledgeRender( + payload, + false, + Boolean(mountedCurrentSlot()), + error instanceof Error ? error.message : String(error), + ); + }) + .finally(() => { + if (applyController !== controller) return; + applyController = null; + document.documentElement.removeAttribute("data-bc-pending-generation"); + }) + .catch(() => {}); + } + + async function applyModes(payload) { + if (typeof payload.fish === "boolean") desiredModes.fish = payload.fish; + if (typeof payload.muted === "boolean") desiredModes.muted = payload.muted; + if (["dark", "light", "auto"].includes(payload.tone)) desiredModes.tone = payload.tone; + document.documentElement.dataset.bcTone = desiredModes.tone; + syncDshTheme(); + if (desiredModes.fish && document.documentElement.dataset.bcActive === "true") { + document.documentElement.dataset.bcFish = "true"; } else { document.documentElement.removeAttribute("data-bc-fish"); } const video = activeVideo(); + const candidate = pendingVideo(); + if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted; if (video instanceof HTMLVideoElement) { try { - await playWithPreference(video); - } catch { - playbackBlocked = desiredModes.muted === false; - } - if (activePayload?.media === "video") { - await acknowledgeRender(activePayload, video.readyState >= 2 && !video.paused, true); - } - } else { - playbackBlocked = false; - } - await acknowledgeMode(); - } - - const events = new EventSource( - `/__beauticode/events?clientId=${encodeURIComponent(clientId)}`, - ); - events.onmessage = (event) => { - try { + playbackBlocked = await playWithPreference(video); + } catch { + playbackBlocked = desiredModes.muted === false; + } + if ( + committedPayload?.media === "video" && + activePayload === committedPayload && + renderPhase === "ready" + ) { + await acknowledgeRender(committedPayload, true, true); + } + } else { + playbackBlocked = false; + } + await acknowledgeMode(); + } + + const events = new EventSource( + `/__beauticode/events?clientId=${encodeURIComponent(clientId)}`, + ); + events.onmessage = (event) => { + try { const payload = JSON.parse(event.data); - if (payload?.type === "mode") void applyModes(payload); - else if (payload?.type === "apply") void applyBackground(payload); - } catch { - /* EventSource will continue with the next valid frame. */ - } - }; - + if (payload?.type === "mode") void applyModes(payload).catch(() => {}); + else if (payload?.type === "apply") scheduleBackground(payload); + } catch { + /* EventSource will continue with the next valid frame. */ + } + }; + setInterval(() => { - const video = activeVideo(); - if (activePayload?.media === "video" && video instanceof HTMLVideoElement) { - void acknowledgeRender(activePayload, video.readyState >= 2 && !video.paused, true); + if ( + !committedPayload || + activePayload !== committedPayload || + renderPhase !== "ready" + ) { + return; + } + if (committedPayload.media === "clear") { + void acknowledgeRender(committedPayload, true, false).catch(() => {}); + return; } - }, 1_000); -})(); + const slot = mountedCurrentSlot(); + if (!slot) return; + if (committedPayload.media === "image") { + const image = slot.querySelector?.("img"); + if ( + image?.isConnected === true && + image.complete && + image.naturalWidth > 0 && + image.naturalHeight > 0 + ) { + void acknowledgeRender(committedPayload, true, true).catch(() => {}); + } + return; + } + const video = activeVideo(); + if ( + committedPayload.media === "video" && + video instanceof HTMLVideoElement + ) { + // A heartbeat is observational, not a second render verdict. Playback can + // briefly pause while Chromium changes modes or refills an 8K buffer; do + // not downgrade an already-rendered generation or fail a pending one. + void acknowledgeRender(committedPayload, true, true).catch(() => {}); + } + }, 1_000); +})(); diff --git a/integrations/deepseek-harness/console.js b/integrations/deepseek-harness/console.js index 31e6c27..41acd66 100644 --- a/integrations/deepseek-harness/console.js +++ b/integrations/deepseek-harness/console.js @@ -1,303 +1,534 @@ -(() => { - "use strict"; - if (window.__beauticodeConsoleLoaded) return; - window.__beauticodeConsoleLoaded = true; - - const IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.avif,image/jpeg,image/png,image/webp,image/avif"; - const VIDEO_ACCEPT = ".mp4,video/mp4"; - - const style = document.createElement("style"); - style.dataset.beauticodeConsole = "true"; - style.textContent = ` -#beauticode-console{display:contents} -#beauticode-console .bc-trigger{cursor:pointer;width:calc(100% + 8px);height:34px;margin:4px -4px;padding:6px 2px 6px 10px;border:none;border-radius:12px;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:22px;align-items:center;gap:8px;display:flex;overflow:hidden} -#beauticode-console .bc-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)} -#beauticode-console.rail .bc-trigger{width:36px;height:36px;margin:8px 0 10px;padding:0;border-radius:50%;justify-content:center;gap:0} -#beauticode-console.rail .bc-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} -#beauticode-console .bc-icon{flex:none;display:block} -#beauticode-console .bc-label{white-space:nowrap;overflow:hidden} -#beauticode-console-pop{position:fixed;z-index:2000;box-sizing:border-box;width:220px;padding:10px;border:1px solid rgba(255,255,255,.08);border-radius:16px;background:#2c323c;color:#e8eaed;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.28));font:inherit;font-size:13px} -#beauticode-console-pop *{box-sizing:border-box;font-family:inherit} -body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.08)} -#beauticode-console-pop .bc-status{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:18px;margin:0 0 8px} -#beauticode-console-pop .bc-row{display:flex;gap:6px;margin:0 0 6px} -#beauticode-console-pop .bc-btn,#beauticode-console-pop .bc-theme-toggle{cursor:pointer;flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid rgba(255,255,255,.1);border-radius:12px;background:rgba(255,255,255,.06);color:inherit;font-size:13px;line-height:20px} -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn, -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle{border-color:rgba(0,0,0,.08);background:#f4f6f8} -#beauticode-console-pop .bc-btn:hover,#beauticode-console-pop .bc-theme-toggle:hover{background:rgba(255,255,255,.12)} -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn:hover, -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{background:#eceff3} -#beauticode-console-pop .bc-btn:disabled,#beauticode-console-pop .bc-theme-toggle:disabled,#beauticode-console-pop .bc-theme-item:disabled{opacity:.45;cursor:default} -#beauticode-console-pop .bc-btn.on{background:rgba(255,255,255,.16)} -#beauticode-console-pop .bc-theme-toggle{width:100%;text-align:left} -#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} -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8} -#beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;width:100%;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} -#beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)} -body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)} -#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} -#beauticode-console-file{display:none !important} -`; - document.head.append(style); - - const host = document.createElement("div"); - host.id = "beauticode-console"; - host.innerHTML = - ''; - - const pop = document.createElement("div"); - pop.id = "beauticode-console-pop"; - pop.hidden = true; - pop.innerHTML = - '

未就绪

' + - '
' + - '' + - '' + - "
" + - '
' + - '' + - '' + - "
" + - '" + - ''; - - const fileInput = document.createElement("input"); - fileInput.id = "beauticode-console-file"; - fileInput.type = "file"; - - document.body.append(pop, fileInput); - - const trigger = host.querySelector(".bc-trigger"); - const statusEl = pop.querySelector(".bc-status"); - const soundBtn = pop.querySelector('[data-act="sound"]'); - const themesBox = pop.querySelector(".bc-themes"); - const themeToggle = pop.querySelector(".bc-theme-toggle"); - const themeList = pop.querySelector(".bc-theme-list"); - const msgEl = pop.querySelector(".bc-msg"); - let busy = false; - let muted = true; - let currentThemeId = ""; - - function findSettingsTrigger() { - const buttons = [...document.querySelectorAll('button[aria-haspopup="dialog"]')]; - const candidates = buttons.filter((button) => { - if (host.contains(button) || pop.contains(button)) return false; - const rect = button.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0 && rect.left < 320 && rect.bottom > window.innerHeight * 0.35; - }); - candidates.sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom); - return candidates[0] || null; - } - - function placePop() { - const rect = trigger.getBoundingClientRect(); - if (!rect.width) return; - pop.style.left = `${Math.round(rect.left)}px`; - pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`; - } - - function place() { - const settings = findSettingsTrigger(); - if (!settings || !settings.parentElement) { - if (host.parentElement) host.remove(); - return; - } - if (host.parentElement !== settings.parentElement || host.nextElementSibling !== settings) { - settings.parentElement.insertBefore(host, settings); - } - host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40); - if (!pop.hidden) placePop(); - } - - function setOpen(open) { - pop.hidden = !open; - trigger.setAttribute("aria-expanded", open ? "true" : "false"); - if (open) { - placePop(); - void refresh(); - } - } - - function showMessage(text) { - if (!text) { - msgEl.hidden = true; - msgEl.textContent = ""; - return; - } - msgEl.hidden = false; - msgEl.textContent = text; - } - - function renderStatus(data) { - if (!data?.ok) { - statusEl.textContent = data?.error || "未就绪"; - return; - } - const label = - data.atmosphere === "gallery" - ? "画窗" - : data.media === "video" - ? "视频" - : data.media === "image" - ? "图片" - : "无背景"; - statusEl.textContent = label; - if (data.atmosphere === "gallery") currentThemeId = "builtin-gallery"; - muted = data.muted !== false; - soundBtn.classList.toggle("on", !muted); - soundBtn.textContent = muted ? "声音" : "声音开"; - const themes = Array.isArray(data.themes) ? data.themes : []; - if (themes.length === 0) { - themesBox.hidden = true; - themeList.innerHTML = ""; - themeList.hidden = true; - themeToggle.setAttribute("aria-expanded", "false"); - return; - } - themesBox.hidden = false; - themeList.innerHTML = themes - .map( - (theme) => - ``, - ) - .join(""); - const selected = themes.find((theme) => theme.id === currentThemeId); - themeToggle.textContent = selected ? selected.name : "已保存主题"; - } - - function escapeText(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); - } - - function escapeAttr(value) { - return escapeText(value).replaceAll('"', """); - } - - async function request(path, init) { - const response = await fetch(path, { - ...init, - headers: { - ...(init?.headers || {}), - }, - }); - const body = await response.json().catch(() => null); - if (!response.ok || body?.ok === false) { - throw new Error(body?.error || `请求失败(${response.status})`); - } - return body; - } - - async function refresh() { - try { - renderStatus(await request("/__beauticode/ui/status")); - } catch (error) { - renderStatus({ ok: false, error: error instanceof Error ? error.message : String(error) }); - } - } - - async function run(task) { - if (busy) return; - busy = true; - for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = true; - showMessage(""); - try { - const result = await task(); - if (result?.message) showMessage(result.message); - await refresh(); - } catch (error) { - showMessage(error instanceof Error ? error.message : String(error)); - } finally { - busy = false; - for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = false; - } - } - - trigger.addEventListener("click", (event) => { - event.stopPropagation(); - setOpen(pop.hidden); - }); - pop.querySelector('[data-act="image"]').addEventListener("click", () => { - fileInput.accept = IMAGE_ACCEPT; - fileInput.click(); - }); - pop.querySelector('[data-act="video"]').addEventListener("click", () => { - fileInput.accept = VIDEO_ACCEPT; - fileInput.click(); - }); - pop.querySelector('[data-act="clear"]').addEventListener("click", () => { - currentThemeId = ""; - void run(() => request("/__beauticode/ui/clear", { method: "POST" })); - }); - soundBtn.addEventListener("click", () => { - void run(() => - request("/__beauticode/ui/mode", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ muted: !muted }), - }), - ); - }); - themeToggle.addEventListener("click", (event) => { - event.stopPropagation(); - const open = themeList.hidden; - themeList.hidden = !open; - themeToggle.setAttribute("aria-expanded", open ? "true" : "false"); - }); - themeList.addEventListener("click", (event) => { - const item = event.target.closest("[data-theme-id]"); - if (!item) return; - currentThemeId = item.getAttribute("data-theme-id") || ""; - themeList.hidden = true; - themeToggle.setAttribute("aria-expanded", "false"); - globalThis.BeauticodeAtmosphere?.setWindowMode?.( - currentThemeId === "builtin-gallery" ? "on" : "closed", - ); - void run(() => - request("/__beauticode/ui/theme/use", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: currentThemeId }), - }), - ); - }); - fileInput.addEventListener("change", () => { - const file = fileInput.files?.[0]; - fileInput.value = ""; - if (!file) return; - currentThemeId = ""; - void run(() => - request("/__beauticode/ui/import", { - method: "POST", - headers: { "x-beauticode-filename": encodeURIComponent(file.name) }, - body: file, - }), - ); - }); - - document.addEventListener("click", (event) => { - if (pop.hidden) return; - if (pop.contains(event.target) || trigger.contains(event.target)) return; - setOpen(false); - }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !pop.hidden) setOpen(false); - }); - - const observer = new MutationObserver(() => place()); - observer.observe(document.documentElement, { childList: true, subtree: true }); - window.addEventListener("resize", place); - setInterval(place, 500); - place(); -})(); +(() => { + "use strict"; + if (window.__beauticodeConsoleLoaded) return; + window.__beauticodeConsoleLoaded = true; + + const IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.avif,image/jpeg,image/png,image/webp,image/avif"; + const VIDEO_ACCEPT = ".mp4,video/mp4"; + + const style = document.createElement("style"); + style.dataset.beauticodeConsole = "true"; + style.textContent = ` +#beauticode-console{display:contents} +#beauticode-console .bc-trigger{cursor:pointer;width:calc(100% + 8px);height:34px;margin:4px -4px;padding:6px 2px 6px 10px;border:none;border-radius:12px;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:22px;align-items:center;gap:8px;display:flex;overflow:hidden} +#beauticode-console .bc-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)} +#beauticode-console.rail .bc-trigger{width:36px;height:36px;margin:8px 0 10px;padding:0;border-radius:50%;justify-content:center;gap:0} +#beauticode-console.rail .bc-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} +#beauticode-console .bc-icon{flex:none;display:block} +#beauticode-console .bc-label{white-space:nowrap;overflow:hidden} +#beauticode-console-pop{position:fixed;z-index:2000;box-sizing:border-box;width:256px;padding:0 14px 12px;border:1px solid rgba(23,26,29,.24);border-top:4px solid #252a30;border-radius:2px;background:#ece9e2;color:#202327;box-shadow:0 16px 36px rgba(0,0,0,.34);font:inherit;font-size:13px} +#beauticode-console-pop *{box-sizing:border-box;font-family:inherit} +body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:#202327;border-color:rgba(23,26,29,.22)} +#beauticode-console-pop .bc-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;padding:12px 0 10px;border-bottom:1px solid #b9b6af} +#beauticode-console-pop .bc-title{margin:0;font:650 16px/20px Georgia,"Songti SC","STSong",serif;letter-spacing:.02em} +#beauticode-console-pop .bc-status{min-width:0;max-width:142px;color:#686d71;font:10px/15px ui-monospace,"Cascadia Mono",monospace;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#beauticode-console-pop .bc-import{cursor:pointer;display:grid;grid-template-columns:34px minmax(0,1fr) 18px;align-items:center;width:100%;min-height:49px;padding:0;border:0;border-bottom:1px solid #c8c5be;background:transparent;color:inherit;text-align:left} +#beauticode-console-pop .bc-import:hover{background:rgba(32,35,39,.05)} +#beauticode-console-pop .bc-index{color:#74787b;font:10px ui-monospace,"Cascadia Mono",monospace} +#beauticode-console-pop .bc-import-copy{min-width:0} +#beauticode-console-pop .bc-import strong{display:block;font-size:13px;line-height:17px;font-weight:650} +#beauticode-console-pop .bc-import small{display:block;color:#717579;font-size:10px;line-height:14px} +#beauticode-console-pop .bc-arrow{font-size:16px;text-align:right} +#beauticode-console-pop .bc-controls{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid #c8c5be} +#beauticode-console-pop .bc-link{cursor:pointer;height:auto;padding:0;border:0;background:transparent;color:#595e62;font-size:11px;line-height:18px;text-decoration:underline;text-underline-offset:3px} +#beauticode-console-pop .bc-link:hover{color:#171a1d} +#beauticode-console-pop .bc-theme-toggle{cursor:pointer;display:flex;align-items:center;justify-content:space-between;width:100%;height:31px;padding:8px 0 4px;border:0;background:transparent;color:#686d71;font:10px ui-monospace,"Cascadia Mono",monospace;letter-spacing:.08em;text-align:left} +#beauticode-console-pop .bc-theme-list{max-height:150px;overflow:auto;scrollbar-width:thin} +#beauticode-console-pop .bc-theme-row{display:flex;align-items:center;min-width:0;border-bottom:1px dotted #bbb8b1} +#beauticode-console-pop .bc-theme-row:last-child{border-bottom:0} +#beauticode-console-pop .bc-theme-item{cursor:pointer;display:flex;align-items:center;flex:1;min-width:0;height:31px;padding:0;border:0;background:transparent;color:inherit;font-size:12px;line-height:31px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#beauticode-console-pop .bc-theme-item::before{content:"";flex:none;width:16px;font-size:9px} +#beauticode-console-pop .bc-theme-item[aria-current="true"]{font-weight:650} +#beauticode-console-pop .bc-theme-item[aria-current="true"]::before{content:"●"} +#beauticode-console-pop .bc-theme-item:hover{background:rgba(32,35,39,.05)} +#beauticode-console-pop .bc-source{margin-left:auto;padding-left:6px;color:#777b7e;font:9px ui-monospace,"Cascadia Mono",monospace} +#beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:22px;height:28px;padding:0;border:0;background:transparent;color:#777b7e;font-size:15px;line-height:28px;opacity:.66} +#beauticode-console-pop .bc-theme-del:hover{color:#802f2f;opacity:1} +#beauticode-console-pop .bc-btn:disabled,#beauticode-console-pop .bc-theme-toggle:disabled,#beauticode-console-pop .bc-theme-item:disabled,#beauticode-console-pop .bc-theme-del:disabled{opacity:.38;cursor:default} +#beauticode-console-pop[data-busy="true"] .bc-head::before{content:"";width:5px;height:5px;margin:0 0 6px;background:#6d7f8c;animation:bc-pulse .9s steps(2,end) infinite} +#beauticode-console-pop .bc-msg{margin:9px 0 0;color:#656a6e;font-size:11px;line-height:16px;max-height:3.2em;overflow:hidden} +@keyframes bc-pulse{50%{opacity:.25}} +#beauticode-console-file{display:none !important} +#beauticode-name-dialog{position:fixed;inset:0;z-index:3000;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);font:inherit} +#beauticode-name-dialog .bc-name-card{display:flex;flex-direction:column;gap:10px;width:min(380px,calc(100vw - 48px));padding:20px;border:1px solid #aaa69e;border-top:4px solid #252a30;border-radius:2px;background:#ece9e2;color:#202327;box-shadow:0 18px 60px rgba(0,0,0,.35)} +#beauticode-name-dialog .bc-name-title{margin:0;font:650 17px Georgia,"Songti SC","STSong",serif} +#beauticode-name-dialog .bc-name-file,#beauticode-name-dialog .bc-name-note,#beauticode-name-dialog .bc-name-error{margin:0;color:#666b6f;font-size:12px;line-height:18px;overflow-wrap:anywhere} +#beauticode-name-dialog .bc-name-error{color:#8d3030} +#beauticode-name-dialog input{height:38px;padding:0 10px;border:1px solid #a8a49c;border-radius:0;background:#f7f4ed;color:inherit;font:inherit;font-size:13px} +#beauticode-name-dialog .bc-name-actions{display:flex;justify-content:flex-end;gap:8px} +#beauticode-name-dialog button{cursor:pointer;height:34px;padding:0 13px;border:1px solid #8d8a84;border-radius:0;background:transparent;color:inherit;font:inherit} +#beauticode-name-dialog button[data-name="confirm"]{background:#252a30;border-color:#252a30;color:#f7f4ed} +`; + document.head.append(style); + + const host = document.createElement("div"); + host.id = "beauticode-console"; + host.innerHTML = + ''; + + const pop = document.createElement("div"); + pop.id = "beauticode-console-pop"; + pop.hidden = true; + pop.innerHTML = + '

背景清单

未就绪
' + + '' + + '' + + '
' + + '' + + '' + + '' + + "
" + + '" + + ''; + + const fileInput = document.createElement("input"); + fileInput.id = "beauticode-console-file"; + fileInput.type = "file"; + + document.body.append(pop, fileInput); + + const trigger = host.querySelector(".bc-trigger"); + const statusEl = pop.querySelector(".bc-status"); + const soundBtn = pop.querySelector('[data-act="sound"]'); + const themesBox = pop.querySelector(".bc-themes"); + const themeToggle = pop.querySelector(".bc-theme-toggle"); + const themeList = pop.querySelector(".bc-theme-list"); + const msgEl = pop.querySelector(".bc-msg"); + let busy = false; + let muted = true; + let currentThemeId = ""; + let themesExpanded = true; + + function findSettingsTrigger() { + const buttons = [...document.querySelectorAll('button[aria-haspopup="dialog"]')]; + const candidates = buttons.filter((button) => { + if (host.contains(button) || pop.contains(button)) return false; + const rect = button.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left < 320 && rect.bottom > window.innerHeight * 0.35; + }); + candidates.sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom); + return candidates[0] || null; + } + + function placePop() { + const rect = trigger.getBoundingClientRect(); + if (!rect.width) return; + pop.style.left = `${Math.round(rect.left)}px`; + pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`; + } + + function place() { + const settings = findSettingsTrigger(); + if (!settings || !settings.parentElement) { + if (host.parentElement) host.remove(); + return; + } + if (host.parentElement !== settings.parentElement || host.nextElementSibling !== settings) { + settings.parentElement.insertBefore(host, settings); + } + host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40); + if (!pop.hidden) placePop(); + } + + function setOpen(open) { + pop.hidden = !open; + trigger.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) { + placePop(); + void refresh(); + } + } + + function showMessage(text) { + if (!text) { + msgEl.hidden = true; + msgEl.textContent = ""; + return; + } + msgEl.hidden = false; + msgEl.textContent = text; + } + + function renderStatus(data) { + if (!data?.ok) { + statusEl.textContent = data?.error || "未就绪"; + return; + } + const label = + data.atmosphere === "gallery" + ? "画窗" + : data.media === "video" + ? "视频" + : data.media === "image" + ? "图片" + : "无背景"; + const sourceLabel = + data.sourceMode === "local" + ? "本地引用" + : data.sourceMode === "managed" + ? "托管副本" + : ""; + if (typeof data.themeId === "string" && data.themeId) { + currentThemeId = data.themeId; + } else if (data.atmosphere === "gallery") { + currentThemeId = "builtin-gallery"; + } else { + currentThemeId = ""; + } + muted = data.muted !== false; + soundBtn.classList.toggle("on", !muted); + soundBtn.textContent = muted ? "声音已关" : "声音已开"; + const themes = Array.isArray(data.themes) ? data.themes : []; + const selected = themes.find((theme) => theme.id === currentThemeId); + const currentLabel = selected?.name || label; + const compactSource = sourceLabel === "本地引用" ? "本地" : sourceLabel === "托管副本" ? "托管" : "已应用"; + statusEl.textContent = `${currentLabel} / ${compactSource}`; + if (themes.length === 0) { + themesBox.hidden = true; + themeList.innerHTML = ""; + themeList.hidden = true; + themeToggle.setAttribute("aria-expanded", "false"); + return; + } + themesBox.hidden = false; + themeList.innerHTML = themes + .map((theme) => { + const del = + theme.bundled === true + ? "" + : ``; + const source = + theme.sourceMode === "local" ? "本地" : theme.bundled ? "内置" : "托管"; + const current = theme.id === currentThemeId ? ' aria-current="true"' : ""; + return `
${del}
`; + }) + .join(""); + themeToggle.innerHTML = `SAVED / ${String(themes.length).padStart(2, "0")}${themesExpanded ? "−" : "+"}`; + themeList.hidden = !themesExpanded; + themeToggle.setAttribute("aria-expanded", themesExpanded ? "true" : "false"); + } + + function escapeText(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + } + + function escapeAttr(value) { + return escapeText(value).replaceAll('"', """); + } + + async function request(path, init, options = {}) { + const timeoutMs = options.timeoutMs === 0 ? 0 : options.timeoutMs || 45_000; + const controller = timeoutMs > 0 ? new AbortController() : null; + const timer = controller + ? setTimeout(() => controller.abort(new Error("background_request_timeout")), timeoutMs) + : null; + try { + const response = await fetch(path, { + ...init, + ...(controller ? { signal: controller.signal } : {}), + headers: { + ...(init?.headers || {}), + }, + }); + const body = await response.json().catch(() => null); + if (!response.ok || body?.ok === false) { + const error = new Error(body?.error || `请求失败(${response.status})`); + error.status = response.status; + error.code = body?.code || ""; + throw error; + } + return body; + } catch (error) { + if (controller?.signal.aborted) { + throw new Error("背景操作超时,控件已恢复。原背景保持不变,请重试。"); + } + throw error; + } finally { + if (timer) clearTimeout(timer); + } + } + + async function refresh() { + try { + renderStatus(await request("/__beauticode/ui/status")); + } catch (error) { + renderStatus({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + async function run(task) { + if (busy) return; + busy = true; + pop.dataset.busy = "true"; + let afterRun = null; + for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true; + showMessage("正在处理,请稍候…"); + try { + const result = await task(); + if (typeof result?.afterRun === "function") afterRun = result.afterRun; + if (result?.theme?.id) currentThemeId = result.theme.id; + if (result?.message) { + const source = + result.sourceMode === "local" + ? "本地引用,未复制主媒体" + : result.sourceMode === "managed" + ? "托管副本" + : ""; + const totalMs = Number(result.importTimings?.applyAndSaveMs ?? result.timings?.totalMs); + const duration = Number.isFinite(totalMs) ? `${Math.round(totalMs)} ms` : ""; + showMessage([result.message, source, duration].filter(Boolean).join(" · ")); + } else { + showMessage(""); + } + await refresh(); + } catch (error) { + showMessage(error instanceof Error ? error.message : String(error)); + } finally { + busy = false; + delete pop.dataset.busy; + for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false; + } + if (afterRun) queueMicrotask(afterRun); + } + + function validateThemeName(value) { + const name = String(value || "").trim(); + if (!name) return "主题名不能为空。"; + if (name.length > 80) return "主题名不能超过 80 个字符。"; + if (/[<>:"/\\|?*]/.test(name) || /[\u0000-\u001f]/.test(name)) { + return '主题名不能包含 < > : " / \\ | ? * 或控制字符。'; + } + return ""; + } + + function defaultThemeName(fileName) { + const suggested = String(fileName || "") + .replace(/\.[^.]+$/, "") + .trim() + .slice(0, 80); + return suggested || "新主题"; + } + + function askThemeName(fileName, suggestedName, options = {}) { + return new Promise((resolve) => { + const dialog = document.createElement("div"); + dialog.id = "beauticode-name-dialog"; + dialog.innerHTML = + '"; + document.body.append(dialog); + const input = dialog.querySelector('input[aria-label="主题名称"]'); + const errorEl = dialog.querySelector(".bc-name-error"); + let closed = false; + + const close = (value) => { + if (closed) return; + closed = true; + dialog.remove(); + resolve(value); + }; + const confirm = () => { + const error = validateThemeName(input.value); + if (error) { + errorEl.hidden = false; + errorEl.textContent = error; + input.focus(); + return; + } + close(input.value.trim()); + }; + dialog.querySelector('[data-name="confirm"]').addEventListener("click", confirm); + dialog.querySelector('[data-name="cancel"]').addEventListener("click", () => close(null)); + input.addEventListener("input", () => { + errorEl.hidden = true; + errorEl.textContent = ""; + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") confirm(); + if (event.key === "Escape") close(null); + }); + dialog.addEventListener("click", (event) => { + if (event.target === dialog) close(null); + }); + input.focus(); + input.select(); + }); + } + + async function pickAndImport(kind) { + let picked; + try { + picked = await request( + "/__beauticode/ui/pick", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind }), + }, + // The user controls how long the native dialog stays open. Import and + // theme switching still use the bounded request timeout above. + { timeoutMs: 0 }, + ); + } catch (error) { + if (error?.code !== "native_picker_unavailable") throw error; + return { + ok: true, + afterRun: () => { + fileInput.accept = kind === "video" ? VIDEO_ACCEPT : IMAGE_ACCEPT; + fileInput.dataset.compatibilityUpload = "true"; + fileInput.click(); + }, + }; + } + if (picked.cancelled) return { ok: true }; + const themeName = await askThemeName( + picked.name, + picked.suggestedThemeName, + ); + if (!themeName) return { ok: true }; + return request("/__beauticode/ui/import-selected", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ selectionId: picked.selectionId, themeName }), + }); + } + + trigger.addEventListener("click", (event) => { + event.stopPropagation(); + setOpen(pop.hidden); + }); + pop.querySelector('[data-act="image"]').addEventListener("click", () => { + void run(() => pickAndImport("image")); + }); + pop.querySelector('[data-act="video"]').addEventListener("click", () => { + void run(() => pickAndImport("video")); + }); + pop.querySelector('[data-act="gallery"]').addEventListener("click", (event) => { + event.stopPropagation(); + setOpen(false); + if (window.BeauticodeGallery) { + void window.BeauticodeGallery.open(); + return; + } + showMessage("皮肤中心脚本尚未加载。"); + }); + pop.querySelector('[data-act="clear"]').addEventListener("click", () => { + void run(async () => { + const result = await request("/__beauticode/ui/clear", { method: "POST" }); + currentThemeId = ""; + return result; + }); + }); + soundBtn.addEventListener("click", () => { + void run(() => + request("/__beauticode/ui/mode", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ muted: !muted }), + }), + ); + }); + themeToggle.addEventListener("click", (event) => { + event.stopPropagation(); + themesExpanded = !themesExpanded; + themeList.hidden = !themesExpanded; + themeToggle.querySelector("span:last-child").textContent = themesExpanded ? "−" : "+"; + themeToggle.setAttribute("aria-expanded", themesExpanded ? "true" : "false"); + }); + themeList.addEventListener("click", (event) => { + const del = event.target.closest("[data-theme-delete]"); + if (del) { + event.stopPropagation(); + const id = del.getAttribute("data-theme-delete") || ""; + const name = del.getAttribute("data-theme-name") || "主题"; + if (!id) return; + if (!window.confirm(`确定删除主题「${name}」?`)) return; + void run(async () => { + const result = await request("/__beauticode/ui/theme/delete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (currentThemeId === id) currentThemeId = ""; + return result; + }); + return; + } + const item = event.target.closest("[data-theme-id]"); + if (!item) return; + const targetThemeId = item.getAttribute("data-theme-id") || ""; + themeList.hidden = true; + themeToggle.setAttribute("aria-expanded", "false"); + void run(async () => { + const result = await request("/__beauticode/ui/theme/use", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: targetThemeId }), + }); + currentThemeId = targetThemeId; + globalThis.BeauticodeAtmosphere?.setWindowMode?.( + currentThemeId === "builtin-gallery" ? "on" : "closed", + ); + return result; + }); + }); + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + const compatibilityUpload = fileInput.dataset.compatibilityUpload === "true"; + fileInput.value = ""; + delete fileInput.dataset.compatibilityUpload; + if (!file) return; + void run(async () => { + const themeName = await askThemeName(file.name, defaultThemeName(file.name), { + compatibilityUpload, + }); + if (!themeName) return { ok: true }; + return request("/__beauticode/ui/import", { + method: "POST", + headers: { + "x-beauticode-filename": encodeURIComponent(file.name), + "x-beauticode-theme-name": encodeURIComponent(themeName), + }, + body: file, + }); + }); + }); + + document.addEventListener("click", (event) => { + if (pop.hidden) return; + if (pop.contains(event.target) || trigger.contains(event.target)) return; + setOpen(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !pop.hidden) setOpen(false); + }); + document.addEventListener("beauticode-gallery-installed", () => { + void refresh(); + }); + + const observer = new MutationObserver(() => place()); + observer.observe(document.documentElement, { childList: true, subtree: true }); + window.addEventListener("resize", place); + setInterval(place, 500); + place(); +})(); diff --git a/integrations/deepseek-harness/control-client.mjs b/integrations/deepseek-harness/control-client.mjs index 1eae13c..2ea1fd1 100644 --- a/integrations/deepseek-harness/control-client.mjs +++ b/integrations/deepseek-harness/control-client.mjs @@ -1,445 +1,516 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -export const CONTROL_SCHEMA = "beauticode.dsh-control/v1"; -export const CONTROL_FILE = "dsh-control.json"; -export const SESSION_HOST_SCHEMA = "beauticode.session-host/v1"; -export const SESSION_HOST_FILE = "session-host.json"; -export const TRAY_CLAIM_SCHEMA = "beauticode.tray-claim/v1"; -export const TRAY_CLAIM_FILE = "tray-claim.json"; -export const TRAY_MISSING_MESSAGE = - "未找到正在运行的 beautiCode 托盘。请先启动 beautiCode,再导入背景。"; -export const TRAY_STARTING_MESSAGE = "beautiCode 托盘正在启动,请稍后再试。"; - -const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]); -const TOKEN_MIN_LENGTH = 24; -const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); - -export function defaultBeauticodeDataRoot() { - if (process.env.BEAUTICODE_DATA_ROOT) { - return path.resolve(process.env.BEAUTICODE_DATA_ROOT); - } - if (process.env.LOCALAPPDATA) { - return path.join(process.env.LOCALAPPDATA, "beautiCode"); - } - return path.join(os.homedir(), ".beauticode"); -} - -export function controlFilePath(dataRoot) { - return path.join(path.resolve(dataRoot), CONTROL_FILE); -} - -export function sessionHostFilePath(dataRoot) { - return path.join(path.resolve(dataRoot), SESSION_HOST_FILE); -} - -export function trayClaimFilePath(dataRoot) { - return path.join(path.resolve(dataRoot), TRAY_CLAIM_FILE); -} - -async function writeAtomicJson(dataRoot, fileName, payload) { - await fs.mkdir(dataRoot, { recursive: true }); - const file = path.join(dataRoot, fileName); - const tmp = path.join( - dataRoot, - `.${fileName}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`, - ); - const handle = await fs.open(tmp, "w", 0o600); - try { - await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - try { - await fs.unlink(file); - } catch (error) { - if (error && typeof error === "object" && error.code !== "ENOENT") throw error; - } - await fs.rename(tmp, file); - return file; -} - -export function isLoopbackControlUrl(value) { - if (typeof value !== "string") return false; - try { - const url = new URL(value); - return ( - url.protocol === "http:" && - LOOPBACK_HOSTS.has(url.hostname.toLowerCase()) && - !url.username && - !url.password && - (url.pathname === "" || url.pathname === "/") && - !url.search && - !url.hash - ); - } catch { - return false; - } -} - -export function isPidAlive(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error && typeof error === "object" && error.code === "EPERM"; - } -} - -export function stripPathQuotes(value) { - const text = String(value ?? "").trim(); - if (text.length >= 2) { - const first = text[0]; - const last = text[text.length - 1]; - if ((first === '"' && last === '"') || (first === "'" && last === "'")) { - return text.slice(1, -1).trim(); - } - } - return text; -} - -export async function inspectLocalMedia(filePath) { - const raw = stripPathQuotes(filePath); - if (!raw) return { ok: false, error: "必须提供文件路径。" }; - if (!path.isAbsolute(raw)) { - return { ok: false, error: "请使用本机绝对路径。" }; - } - const resolved = path.resolve(raw); - try { - const stat = await fs.lstat(resolved); - if (!stat.isFile() || stat.isSymbolicLink()) { - return { ok: false, error: "路径必须是普通文件,不能是目录或符号链接。" }; - } - } catch { - return { ok: false, error: `找不到文件:${resolved}。请使用本机绝对路径。` }; - } - const ext = path.extname(resolved).toLowerCase(); - if (ext === ".mp4") return { ok: true, kind: "video", path: resolved }; - if (IMAGE_EXTENSIONS.has(ext)) return { ok: true, kind: "image", path: resolved }; - return { - ok: false, - error: "只支持图片(jpg / jpeg / png / webp / avif)或 MP4 视频。", - }; -} - -export function matchSavedTheme(themes, query) { - const needle = String(query ?? "").trim(); - if (!needle) return { ok: false, error: "必须提供主题名称或 ID。" }; - const list = Array.isArray(themes) ? themes : []; - const byId = list.find((theme) => theme.id === needle); - if (byId) return { ok: true, theme: byId }; - const exactName = list.filter((theme) => theme.name === needle); - if (exactName.length === 1) return { ok: true, theme: exactName[0] }; - const lower = needle.toLowerCase(); - const ciName = list.filter((theme) => String(theme.name).toLowerCase() === lower); - if (ciName.length === 1) return { ok: true, theme: ciName[0] }; - const prefixes = list.filter( - (theme) => - String(theme.name).toLowerCase().startsWith(lower) || - String(theme.id).toLowerCase().startsWith(lower), - ); - if (prefixes.length === 1) return { ok: true, theme: prefixes[0] }; - if (prefixes.length > 1) { - return { - ok: false, - error: `有多个主题匹配「${needle}」:${prefixes.map((theme) => theme.name).join("、")}。`, - }; - } - if (list.length === 0) return { ok: false, error: "还没有已保存的主题。" }; - return { - ok: false, - error: `未找到主题「${needle}」。已保存:${list.map((theme) => theme.name).join("、")}。`, - }; -} - -export function formatStatusText(status) { - const background = status?.manifest?.background; - let media = "无"; - if (background?.type === "video") media = "视频"; - else if (background?.type === "image") media = "图片"; - const pages = Number.isInteger(status?.sessions) ? status.sessions : 0; - const fish = status?.fish ? "开" : "关"; - const sound = status?.muted === false ? "开" : "关"; - const tone = status?.tone || "dark"; - const ready = status?.hostReady ? "已连接" : "未就绪"; - return [ - `背景:${media}`, - `页面:${ready}(${pages})`, - `摸鱼:${fish}`, - `声音:${sound}`, - `色调:${tone}`, - ].join(" · "); -} - -export async function writeDshControlFile(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const url = opts.url; - const token = opts.token; - const pid = opts.pid ?? process.pid; - if (!isLoopbackControlUrl(url)) { - throw new Error("DSH control URL must be loopback HTTP."); - } - if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) { - throw new Error("DSH control token is too short."); - } - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error("DSH control pid is invalid."); - } - return writeAtomicJson(dataRoot, CONTROL_FILE, { - schema: CONTROL_SCHEMA, - host: "dsh", - pid, - url: new URL(url).origin, - token, - }); -} - -export async function writeSessionHostFile(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const url = opts.url; - const token = opts.token; - const pid = opts.pid ?? process.pid; - const host = opts.host === "codex" ? "codex" : "dsh"; - if (!isLoopbackControlUrl(url)) { - throw new Error("session-host URL must be loopback HTTP."); - } - if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) { - throw new Error("session-host token is too short."); - } - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error("session-host pid is invalid."); - } - return writeAtomicJson(dataRoot, SESSION_HOST_FILE, { - schema: SESSION_HOST_SCHEMA, - host, - pid, - url: new URL(url).origin, - token, - }); -} - -export async function writeTrayClaim(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const pid = opts.pid ?? process.pid; - if (!Number.isInteger(pid) || pid <= 0) { - throw new Error("tray claim pid is invalid."); - } - return writeAtomicJson(dataRoot, TRAY_CLAIM_FILE, { - schema: TRAY_CLAIM_SCHEMA, - pid, - startedAt: new Date().toISOString(), - }); -} - -export async function removeDshControlFile(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const pid = opts.pid ?? process.pid; - const file = controlFilePath(dataRoot); - try { - const current = await readDshControlFile(dataRoot, { allowDead: true }); - if (!current || current.pid !== pid) return false; - await fs.unlink(file); - return true; - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return false; - throw error; - } -} - -export async function readDshControlFile(dataRoot, opts = {}) { - const file = controlFilePath(dataRoot); - let raw; - try { - raw = await fs.readFile(file, "utf8"); - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return null; - throw error; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - !parsed || - parsed.schema !== CONTROL_SCHEMA || - parsed.host !== "dsh" || - typeof parsed.token !== "string" || - parsed.token.length < TOKEN_MIN_LENGTH || - !isLoopbackControlUrl(parsed.url) || - !Number.isInteger(parsed.pid) || - parsed.pid <= 0 - ) { - return null; - } - if (!opts.allowDead && !isPidAlive(parsed.pid)) return null; - return { - schema: CONTROL_SCHEMA, - host: "dsh", - pid: parsed.pid, - url: new URL(parsed.url).origin, - token: parsed.token, - }; -} - -export async function removeSessionHostFile(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const pid = opts.pid ?? process.pid; - const file = sessionHostFilePath(dataRoot); - try { - const current = await readSessionHostFile(dataRoot, { allowDead: true }); - if (!current || current.pid !== pid) return false; - await fs.unlink(file); - return true; - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return false; - throw error; - } -} - -export async function readSessionHostFile(dataRoot, opts = {}) { - const file = sessionHostFilePath(dataRoot); - let raw; - try { - raw = await fs.readFile(file, "utf8"); - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return null; - throw error; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - !parsed || - parsed.schema !== SESSION_HOST_SCHEMA || - (parsed.host !== "dsh" && parsed.host !== "codex") || - typeof parsed.token !== "string" || - parsed.token.length < TOKEN_MIN_LENGTH || - !isLoopbackControlUrl(parsed.url) || - !Number.isInteger(parsed.pid) || - parsed.pid <= 0 - ) { - return null; - } - if (!opts.allowDead && !isPidAlive(parsed.pid)) return null; - return { - schema: SESSION_HOST_SCHEMA, - host: parsed.host, - pid: parsed.pid, - url: new URL(parsed.url).origin, - token: parsed.token, - }; -} - -export async function readTrayClaim(dataRoot, opts = {}) { - const file = trayClaimFilePath(dataRoot); - let raw; - try { - raw = await fs.readFile(file, "utf8"); - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return null; - throw error; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - !parsed || - parsed.schema !== TRAY_CLAIM_SCHEMA || - !Number.isInteger(parsed.pid) || - parsed.pid <= 0 - ) { - return null; - } - if (!opts.allowDead && !isPidAlive(parsed.pid)) return null; - return { - schema: TRAY_CLAIM_SCHEMA, - pid: parsed.pid, - startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null, - }; -} - -export async function removeTrayClaim(opts) { - const dataRoot = path.resolve(opts.dataRoot); - const pid = opts.pid ?? process.pid; - const file = trayClaimFilePath(dataRoot); - try { - const current = await readTrayClaim(dataRoot, { allowDead: true }); - if (!current || current.pid !== pid) return false; - await fs.unlink(file); - return true; - } catch (error) { - if (error && typeof error === "object" && error.code === "ENOENT") return false; - throw error; - } -} - -function mergeSignals(userSignal, timeoutMs) { - const timeout = AbortSignal.timeout(timeoutMs); - if (!userSignal) return timeout; - return AbortSignal.any([userSignal, timeout]); -} - -export async function callDshControl(dataRoot, spec) { - const control = await readDshControlFile(dataRoot); - if (!control) { - const error = new Error(TRAY_MISSING_MESSAGE); - error.code = "TRAY_MISSING"; - throw error; - } - const timeoutMs = spec.timeoutMs ?? 180_000; - const signal = mergeSignals(spec.signal, timeoutMs); - if (typeof spec.path !== "string" || !spec.path.startsWith("/") || spec.path.startsWith("//")) { - throw new Error("DSH control path is invalid."); - } - const url = new URL(spec.path, `${control.url}/`); - if (!isLoopbackControlUrl(control.url) || url.origin !== control.url) { - throw new Error("DSH control URL must be loopback HTTP."); - } - let response; - try { - response = await fetch(url, { - method: spec.method, - headers: { - authorization: `Bearer ${control.token}`, - ...(spec.body !== undefined ? { "content-type": "application/json" } : {}), - }, - body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined, - signal, - }); - } catch (error) { - if (error && typeof error === "object" && error.name === "AbortError") { - throw new Error("导入已取消或超时。"); - } - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`无法连接 beautiCode 托盘(${detail})。`); - } - let payload = null; - try { - payload = await response.json(); - } catch { - throw new Error(`托盘返回了无法解析的响应(HTTP ${response.status})。`); - } - if (!response.ok || payload?.ok === false) { - const message = - typeof payload?.error === "string" - ? payload.error - : `托盘请求失败(HTTP ${response.status})。`; - const error = new Error(message); - error.statusCode = response.status; - error.payload = payload; - throw error; - } - return payload; -} +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const CONTROL_SCHEMA = "beauticode.dsh-control/v1"; +export const CONTROL_FILE = "dsh-control.json"; +export const SESSION_HOST_SCHEMA = "beauticode.session-host/v1"; +export const SESSION_HOST_FILE = "session-host.json"; +export const TRAY_CLAIM_SCHEMA = "beauticode.tray-claim/v1"; +export const TRAY_CLAIM_FILE = "tray-claim.json"; +export const TRAY_MISSING_MESSAGE = + "未找到正在运行的 beautiCode 托盘。请先启动 beautiCode,再导入背景。"; +export const TRAY_STARTING_MESSAGE = "beautiCode 托盘正在启动,请稍后再试。"; + +const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]); +const TOKEN_MIN_LENGTH = 24; +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +export function defaultBeauticodeDataRoot() { + if (process.env.BEAUTICODE_DATA_ROOT) { + return path.resolve(process.env.BEAUTICODE_DATA_ROOT); + } + if (process.env.LOCALAPPDATA) { + return path.join(process.env.LOCALAPPDATA, "beautiCode"); + } + return path.join(os.homedir(), ".beauticode"); +} + +export function controlFilePath(dataRoot) { + return path.join(path.resolve(dataRoot), CONTROL_FILE); +} + +export function sessionHostFilePath(dataRoot) { + return path.join(path.resolve(dataRoot), SESSION_HOST_FILE); +} + +export function trayClaimFilePath(dataRoot) { + return path.join(path.resolve(dataRoot), TRAY_CLAIM_FILE); +} + +async function writeAtomicJson(dataRoot, fileName, payload) { + await fs.mkdir(dataRoot, { recursive: true }); + const file = path.join(dataRoot, fileName); + const tmp = path.join( + dataRoot, + `.${fileName}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`, + ); + const handle = await fs.open(tmp, "w", 0o600); + try { + await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.unlink(file); + } catch (error) { + if (error && typeof error === "object" && error.code !== "ENOENT") throw error; + } + await fs.rename(tmp, file); + return file; +} + +export function isLoopbackControlUrl(value) { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return ( + url.protocol === "http:" && + LOOPBACK_HOSTS.has(url.hostname.toLowerCase()) && + !url.username && + !url.password && + (url.pathname === "" || url.pathname === "/") && + !url.search && + !url.hash + ); + } catch { + return false; + } +} + +export function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && typeof error === "object" && error.code === "EPERM"; + } +} + +let livenessModulePromise; + +function loadCoreLiveness() { + if (!livenessModulePromise) { + livenessModulePromise = (async () => { + const candidates = [ + "@beauticode/core", + new URL("./vendor/core/index.js", import.meta.url).href, + new URL("../../packages/core/dist/index.js", import.meta.url).href, + ]; + for (const specifier of candidates) { + try { + const mod = await import(specifier); + if (typeof mod.isRecordedPidLive === "function") return mod; + } catch { + /* try the next resolution path */ + } + } + return null; + })(); + } + return livenessModulePromise; +} + +async function isLiveRecordedPid(pid, startedAt, mtimeMs) { + if (!isPidAlive(pid)) return false; + const recorded = + typeof startedAt === "string" && startedAt + ? startedAt + : Number.isFinite(mtimeMs) + ? new Date(mtimeMs).toISOString() + : null; + try { + const core = await loadCoreLiveness(); + if (core) return await core.isRecordedPidLive(pid, recorded); + } catch { + /* fall back to the cheap PID check */ + } + return true; +} + +export function stripPathQuotes(value) { + const text = String(value ?? "").trim(); + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1).trim(); + } + } + return text; +} + +export async function inspectLocalMedia(filePath) { + const raw = stripPathQuotes(filePath); + if (!raw) return { ok: false, error: "必须提供文件路径。" }; + if (!path.isAbsolute(raw)) { + return { ok: false, error: "请使用本机绝对路径。" }; + } + const resolved = path.resolve(raw); + try { + const stat = await fs.lstat(resolved); + if (!stat.isFile() || stat.isSymbolicLink()) { + return { ok: false, error: "路径必须是普通文件,不能是目录或符号链接。" }; + } + } catch { + return { ok: false, error: `找不到文件:${resolved}。请使用本机绝对路径。` }; + } + const ext = path.extname(resolved).toLowerCase(); + if (ext === ".mp4") return { ok: true, kind: "video", path: resolved }; + if (IMAGE_EXTENSIONS.has(ext)) return { ok: true, kind: "image", path: resolved }; + return { + ok: false, + error: "只支持图片(jpg / jpeg / png / webp / avif)或 MP4 视频。", + }; +} + +export function matchSavedTheme(themes, query) { + const needle = String(query ?? "").trim(); + if (!needle) return { ok: false, error: "必须提供主题名称或 ID。" }; + const list = Array.isArray(themes) ? themes : []; + const byId = list.find((theme) => theme.id === needle); + if (byId) return { ok: true, theme: byId }; + const exactName = list.filter((theme) => theme.name === needle); + if (exactName.length === 1) return { ok: true, theme: exactName[0] }; + const lower = needle.toLowerCase(); + const ciName = list.filter((theme) => String(theme.name).toLowerCase() === lower); + if (ciName.length === 1) return { ok: true, theme: ciName[0] }; + const prefixes = list.filter( + (theme) => + String(theme.name).toLowerCase().startsWith(lower) || + String(theme.id).toLowerCase().startsWith(lower), + ); + if (prefixes.length === 1) return { ok: true, theme: prefixes[0] }; + if (prefixes.length > 1) { + return { + ok: false, + error: `有多个主题匹配「${needle}」:${prefixes.map((theme) => theme.name).join("、")}。`, + }; + } + if (list.length === 0) return { ok: false, error: "还没有已保存的主题。" }; + return { + ok: false, + error: `未找到主题「${needle}」。已保存:${list.map((theme) => theme.name).join("、")}。`, + }; +} + +export function formatStatusText(status) { + const background = status?.manifest?.background; + let media = "无"; + if (background?.type === "video") media = "视频"; + else if (background?.type === "image") media = "图片"; + const pages = Number.isInteger(status?.sessions) ? status.sessions : 0; + const fish = status?.fish ? "开" : "关"; + const sound = status?.muted === false ? "开" : "关"; + const tone = status?.tone || "dark"; + const ready = status?.hostReady ? "已连接" : "未就绪"; + return [ + `背景:${media}`, + `页面:${ready}(${pages})`, + `摸鱼:${fish}`, + `声音:${sound}`, + `色调:${tone}`, + ].join(" · "); +} + +export async function writeDshControlFile(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const url = opts.url; + const token = opts.token; + const pid = opts.pid ?? process.pid; + if (!isLoopbackControlUrl(url)) { + throw new Error("DSH control URL must be loopback HTTP."); + } + if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) { + throw new Error("DSH control token is too short."); + } + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error("DSH control pid is invalid."); + } + return writeAtomicJson(dataRoot, CONTROL_FILE, { + schema: CONTROL_SCHEMA, + host: "dsh", + pid, + url: new URL(url).origin, + token, + startedAt: new Date().toISOString(), + }); +} + +export async function writeSessionHostFile(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const url = opts.url; + const token = opts.token; + const pid = opts.pid ?? process.pid; + const host = opts.host === "codex" ? "codex" : "dsh"; + if (!isLoopbackControlUrl(url)) { + throw new Error("session-host URL must be loopback HTTP."); + } + if (typeof token !== "string" || token.length < TOKEN_MIN_LENGTH) { + throw new Error("session-host token is too short."); + } + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error("session-host pid is invalid."); + } + return writeAtomicJson(dataRoot, SESSION_HOST_FILE, { + schema: SESSION_HOST_SCHEMA, + host, + pid, + url: new URL(url).origin, + token, + startedAt: new Date().toISOString(), + }); +} + +export async function writeTrayClaim(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const pid = opts.pid ?? process.pid; + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error("tray claim pid is invalid."); + } + return writeAtomicJson(dataRoot, TRAY_CLAIM_FILE, { + schema: TRAY_CLAIM_SCHEMA, + pid, + startedAt: new Date().toISOString(), + }); +} + +export async function removeDshControlFile(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const pid = opts.pid ?? process.pid; + const file = controlFilePath(dataRoot); + try { + const current = await readDshControlFile(dataRoot, { allowDead: true }); + if (!current || current.pid !== pid) return false; + await fs.unlink(file); + return true; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return false; + throw error; + } +} + +export async function readDshControlFile(dataRoot, opts = {}) { + const file = controlFilePath(dataRoot); + let raw; + let mtimeMs = 0; + try { + const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]); + raw = text; + mtimeMs = stat.mtimeMs; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return null; + throw error; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + !parsed || + parsed.schema !== CONTROL_SCHEMA || + parsed.host !== "dsh" || + typeof parsed.token !== "string" || + parsed.token.length < TOKEN_MIN_LENGTH || + !isLoopbackControlUrl(parsed.url) || + !Number.isInteger(parsed.pid) || + parsed.pid <= 0 + ) { + return null; + } + if ( + !opts.allowDead && + !(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs)) + ) { + return null; + } + return { + schema: CONTROL_SCHEMA, + host: "dsh", + pid: parsed.pid, + url: new URL(parsed.url).origin, + token: parsed.token, + startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null, + }; +} + +export async function removeSessionHostFile(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const pid = opts.pid ?? process.pid; + const file = sessionHostFilePath(dataRoot); + try { + const current = await readSessionHostFile(dataRoot, { allowDead: true }); + if (!current || current.pid !== pid) return false; + await fs.unlink(file); + return true; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return false; + throw error; + } +} + +export async function readSessionHostFile(dataRoot, opts = {}) { + const file = sessionHostFilePath(dataRoot); + let raw; + let mtimeMs = 0; + try { + const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]); + raw = text; + mtimeMs = stat.mtimeMs; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return null; + throw error; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + !parsed || + parsed.schema !== SESSION_HOST_SCHEMA || + (parsed.host !== "dsh" && parsed.host !== "codex") || + typeof parsed.token !== "string" || + parsed.token.length < TOKEN_MIN_LENGTH || + !isLoopbackControlUrl(parsed.url) || + !Number.isInteger(parsed.pid) || + parsed.pid <= 0 + ) { + return null; + } + if ( + !opts.allowDead && + !(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs)) + ) { + return null; + } + return { + schema: SESSION_HOST_SCHEMA, + host: parsed.host, + pid: parsed.pid, + url: new URL(parsed.url).origin, + token: parsed.token, + startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null, + }; +} + +export async function readTrayClaim(dataRoot, opts = {}) { + const file = trayClaimFilePath(dataRoot); + let raw; + let mtimeMs = 0; + try { + const [text, stat] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]); + raw = text; + mtimeMs = stat.mtimeMs; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return null; + throw error; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + !parsed || + parsed.schema !== TRAY_CLAIM_SCHEMA || + !Number.isInteger(parsed.pid) || + parsed.pid <= 0 + ) { + return null; + } + if ( + !opts.allowDead && + !(await isLiveRecordedPid(parsed.pid, parsed.startedAt, mtimeMs)) + ) { + return null; + } + return { + schema: TRAY_CLAIM_SCHEMA, + pid: parsed.pid, + startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null, + }; +} + +export async function removeTrayClaim(opts) { + const dataRoot = path.resolve(opts.dataRoot); + const pid = opts.pid ?? process.pid; + const file = trayClaimFilePath(dataRoot); + try { + const current = await readTrayClaim(dataRoot, { allowDead: true }); + if (!current || current.pid !== pid) return false; + await fs.unlink(file); + return true; + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return false; + throw error; + } +} + +function mergeSignals(userSignal, timeoutMs) { + const timeout = AbortSignal.timeout(timeoutMs); + if (!userSignal) return timeout; + return AbortSignal.any([userSignal, timeout]); +} + +export async function callDshControl(dataRoot, spec) { + const control = await readDshControlFile(dataRoot); + if (!control) { + const error = new Error(TRAY_MISSING_MESSAGE); + error.code = "TRAY_MISSING"; + throw error; + } + const timeoutMs = spec.timeoutMs ?? 180_000; + const signal = mergeSignals(spec.signal, timeoutMs); + if (typeof spec.path !== "string" || !spec.path.startsWith("/") || spec.path.startsWith("//")) { + throw new Error("DSH control path is invalid."); + } + const url = new URL(spec.path, `${control.url}/`); + if (!isLoopbackControlUrl(control.url) || url.origin !== control.url) { + throw new Error("DSH control URL must be loopback HTTP."); + } + let response; + try { + response = await fetch(url, { + method: spec.method, + headers: { + authorization: `Bearer ${control.token}`, + ...(spec.body !== undefined ? { "content-type": "application/json" } : {}), + }, + body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined, + signal, + }); + } catch (error) { + if (error && typeof error === "object" && error.name === "AbortError") { + throw new Error("导入已取消或超时。"); + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`无法连接 beautiCode 托盘(${detail})。`); + } + let payload = null; + try { + payload = await response.json(); + } catch { + throw new Error(`托盘返回了无法解析的响应(HTTP ${response.status})。`); + } + if (!response.ok || payload?.ok === false) { + const message = + typeof payload?.error === "string" + ? payload.error + : `托盘请求失败(HTTP ${response.status})。`; + const error = new Error(message); + error.statusCode = response.status; + error.payload = payload; + if (payload?.sourceMode != null) error.sourceMode = payload.sourceMode; + if (payload?.timings != null) error.timings = payload.timings; + throw error; + } + return payload; +} diff --git a/integrations/deepseek-harness/cordis.patch.yml b/integrations/deepseek-harness/cordis.patch.yml index 4b0da46..e89f2c6 100644 --- a/integrations/deepseek-harness/cordis.patch.yml +++ b/integrations/deepseek-harness/cordis.patch.yml @@ -1,4 +1,4 @@ -- insert: - - id: beauticode-bridge - name: '@beauticode/dsh-plugin' - inject: [webServer] +- insert: + - id: beauticode-bridge + name: beauticode-dsh + inject: [webServer] diff --git a/integrations/deepseek-harness/gallery-host.mjs b/integrations/deepseek-harness/gallery-host.mjs new file mode 100644 index 0000000..d6bed2e --- /dev/null +++ b/integrations/deepseek-harness/gallery-host.mjs @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { Readable } from "node:stream"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const SKIN_ID = /^skin-[a-z0-9]{8,40}$/; +const MAX_IMAGE_BYTES = 18 * 1024 * 1024; +const MAX_VIDEO_BYTES = 800 * 1024 * 1024; +const INSTALL_TIMEOUT_MS = 30 * 60 * 1000; +const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]); + +export function isSafeSkinId(id) { + return typeof id === "string" && SKIN_ID.test(id); +} + +export function normalizeSkinCenterUrl(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed); + if (url.username || url.password || url.hash) return null; + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + if (url.protocol === "http:" && !LOOPBACK.has(url.hostname.toLowerCase())) return null; + const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}`; + } catch { + return null; + } +} + +export async function readBundledSkinCenterUrl() { + try { + const raw = JSON.parse(await fsp.readFile(path.join(here, "skin-center.json"), "utf8")); + return normalizeSkinCenterUrl(raw.url); + } catch { + return null; + } +} + +export async function resolveConfiguredSkinCenterUrl() { + return ( + normalizeSkinCenterUrl(process.env.BEAUTICODE_SKIN_CENTER) ?? + (await readBundledSkinCenterUrl()) + ); +} + +export function skinUrl(center, id, part = "") { + const origin = normalizeSkinCenterUrl(center); + if (!origin || !isSafeSkinId(id)) { + throw new Error("Skin center is not configured."); + } + return part ? `${origin}/api/skins/${id}/${part}` : `${origin}/api/skins/${id}`; +} + +export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress } = {}) { + const expected = new URL(url); + if (expectedOrigin && expected.origin !== expectedOrigin) { + throw new Error("Skin media download host mismatch."); + } + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok || !response.body) { + throw new Error("Skin media download failed."); + } + const finalUrl = new URL(response.url); + if (finalUrl.origin !== expected.origin) { + throw new Error("Skin media download host mismatch."); + } + const length = Number(response.headers.get("content-length")); + if (Number.isFinite(length) && length > maxBytes) { + throw new Error("Skin media download exceeded the size limit."); + } + await fsp.mkdir(path.dirname(dest), { recursive: true }); + let size = 0; + const limiter = new Transform({ + transform(chunk, _enc, callback) { + size += chunk.length; + if (size > maxBytes) { + callback(new Error("Skin media download exceeded the size limit.")); + return; + } + onProgress?.(size, Number.isFinite(length) ? length : 0); + callback(null, chunk); + }, + }); + await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(dest)); + return { bytes: size }; +} + +function extensionOf(url, fallback) { + try { + const ext = path.extname(new URL(url).pathname).toLowerCase(); + if (ext) return ext; + } catch { + /* use fallback */ + } + return fallback; +} + +export function createGalleryHandlers({ dataRoot, actions }) { + async function importTheme(input) { + if (typeof actions.importTheme === "function") { + return actions.importTheme(input); + } + throw new Error("当前引擎不支持导入皮肤。"); + } + + return { + async config(req, res, sendJson, isSameOrigin) { + if (req.method !== "GET") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const url = await resolveConfiguredSkinCenterUrl(); + sendJson(res, 200, { ok: true, url, enabled: Boolean(url) }); + }, + + async catalog(req, res, sendJson, isSameOrigin) { + if (req.method !== "GET") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const center = await resolveConfiguredSkinCenterUrl(); + if (!center) { + sendJson(res, 200, { + ok: false, + error: "尚未配置皮肤中心地址。", + skins: [], + }); + return; + } + const incoming = new URL(req.url || "/", "http://127.0.0.1"); + const target = new URL("/api/catalog", `${center}/`); + target.search = incoming.search; + const response = await fetch(target, { headers: { accept: "application/json" } }); + const body = await response.json().catch(() => null); + if (!response.ok || !body || body.ok === false) { + sendJson(res, 422, { + ok: false, + error: body?.error || "无法读取皮肤目录。", + skins: [], + }); + return; + } + sendJson(res, 200, { + ok: true, + skins: Array.isArray(body.skins) ? body.skins : [], + nextCursor: body.nextCursor ?? null, + url: center, + }); + }, + + async install(req, res, sendJson, isSameOrigin, readJson) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + const id = String(body.id ?? "").trim(); + if (!isSafeSkinId(id)) { + sendJson(res, 400, { ok: false, error: "皮肤 ID 无效。" }); + return; + } + const center = await resolveConfiguredSkinCenterUrl(); + if (!center) { + sendJson(res, 422, { ok: false, error: "尚未配置皮肤中心地址。" }); + return; + } + const origin = new URL(center).origin; + res.writeHead(200, { + "content-type": "application/x-ndjson; charset=utf-8", + "cache-control": "no-store", + }); + const write = (payload) => { + if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`); + }; + const tmpDir = path.join(dataRoot, "tmp", "gallery", `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`); + try { + write({ phase: "fetch" }); + const metaRes = await fetch(skinUrl(center, id), { headers: { accept: "application/json" } }); + const meta = await metaRes.json().catch(() => null); + const skin = meta?.skin; + if (!metaRes.ok || !skin || skin.status && skin.status !== "approved") { + throw new Error("Skin is not available for download."); + } + await fsp.mkdir(tmpDir, { recursive: true }); + const imageUrl = skinUrl(center, id, "image"); + const imagePath = path.join(tmpDir, `image${extensionOf(imageUrl, ".png")}`); + write({ phase: "download", part: "image" }); + await downloadToFile(imageUrl, imagePath, { + maxBytes: MAX_IMAGE_BYTES, + expectedOrigin: origin, + onProgress: (done, total) => write({ phase: "download", part: "image", done, total }), + }); + let videoPath; + if (skin.type === "video") { + const videoUrl = skinUrl(center, id, "video"); + videoPath = path.join(tmpDir, "background.mp4"); + write({ phase: "download", part: "video" }); + await downloadToFile(videoUrl, videoPath, { + maxBytes: MAX_VIDEO_BYTES, + expectedOrigin: origin, + onProgress: (done, total) => write({ phase: "download", part: "video", done, total }), + }); + } + write({ phase: "import" }); + const imported = await importTheme({ + name: String(skin.name || id).slice(0, 80), + imagePath, + ...(videoPath ? { videoPath } : {}), + ...(skin.effects ? { effects: skin.effects } : {}), + source: { kind: "skin-center", skinId: id, centerUrl: center }, + }); + write({ phase: "apply" }); + const applied = await actions.useTheme(imported.theme?.id || imported.id, undefined); + fetch(skinUrl(center, id, "download"), { method: "POST" }).catch(() => {}); + write({ + ok: true, + phase: "done", + theme: imported.theme || imported, + message: applied.message || `已安装并应用「${skin.name}」。`, + }); + } catch (error) { + write({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + res.end(); + } + }, + }; +} + +export const GALLERY_INSTALL_TIMEOUT_MS = INSTALL_TIMEOUT_MS; diff --git a/integrations/deepseek-harness/gallery.js b/integrations/deepseek-harness/gallery.js new file mode 100644 index 0000000..c019340 --- /dev/null +++ b/integrations/deepseek-harness/gallery.js @@ -0,0 +1,178 @@ +(() => { + "use strict"; + if (window.__beauticodeGalleryLoaded) return; + window.__beauticodeGalleryLoaded = true; + + const style = document.createElement("style"); + style.textContent = ` +#beauticode-gallery{position:fixed;inset:0;z-index:3000;display:flex;align-items:center;justify-content:center;background:rgba(11,13,18,.62)} +#beauticode-gallery[hidden]{display:none} +#beauticode-gallery .bcg-panel{width:min(880px,calc(100vw - 32px));height:min(640px,calc(100vh - 32px));display:flex;flex-direction:column;border:1px solid rgba(255,255,255,.08);border-radius:18px;background:#2c323c;color:#e8eaed;box-shadow:0 16px 48px rgba(0,0,0,.4);overflow:hidden} +body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.08)} +#beauticode-gallery .bcg-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.08)} +#beauticode-gallery .bcg-head h2{margin:0;font-size:15px;font-weight:600} +#beauticode-gallery .bcg-head input,#beauticode-gallery .bcg-head select{height:32px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(255,255,255,.06);color:inherit;padding:0 8px} +#beauticode-gallery .bcg-grid{flex:1;overflow:auto;padding:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;align-content:start} +#beauticode-gallery .bcg-card{display:block;border:none;padding:0;border-radius:12px;overflow:hidden;background:#232830;color:inherit;text-align:left;cursor:pointer} +#beauticode-gallery .bcg-card img{width:100%;aspect-ratio:16/10;object-fit:cover;display:block;background:#111} +#beauticode-gallery .bcg-card span{display:block;padding:8px 10px;font-size:13px} +#beauticode-gallery .bcg-msg,#beauticode-gallery .bcg-foot{padding:0 14px 12px;color:#9aa3ad;font-size:12px} +#beauticode-gallery .bcg-close{margin-left:auto} +#beauticode-gallery .bcg-btn{height:32px;padding:0 10px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(255,255,255,.06);color:inherit;cursor:pointer} +#beauticode-gallery .bcg-btn.primary{background:#4d6bfe;border-color:transparent} + `; + document.head.append(style); + + const host = document.createElement("div"); + host.id = "beauticode-gallery"; + host.hidden = true; + host.innerHTML = + '"; + document.body.append(host); + + const grid = host.querySelector(".bcg-grid"); + const msg = host.querySelector(".bcg-msg"); + const foot = host.querySelector(".bcg-foot"); + const queryInput = host.querySelector(".bcg-q"); + const typeSelect = host.querySelector(".bcg-type"); + let centerUrl = ""; + let busy = false; + + function escapeText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + } + + async function request(path, init) { + const response = await fetch(path, init); + if ((response.headers.get("content-type") || "").includes("ndjson")) { + return readNdjson(response); + } + const body = await response.json().catch(() => null); + if (!response.ok || body?.ok === false) { + throw new Error(body?.error || `请求失败(${response.status})`); + } + return body; + } + + async function readNdjson(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let last = null; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + last = JSON.parse(line); + if (last.phase === "download" && last.total) { + const pct = Math.round((last.done / last.total) * 100); + msg.textContent = `正在下载${last.part === "video" ? "视频" : "图片"} ${pct}%`; + } else if (last.phase === "import") { + msg.textContent = "正在写入本机主题…"; + } else if (last.phase === "apply") { + msg.textContent = "正在应用到当前窗口…"; + } + if (last.ok === false) throw new Error(last.error || "安装失败。"); + } + if (done) break; + } + if (last?.ok) return last; + throw new Error(last?.error || "安装失败。"); + } + + async function load() { + msg.textContent = "正在读取目录…"; + const params = new URLSearchParams(); + if (queryInput.value.trim()) params.set("q", queryInput.value.trim()); + if (typeSelect.value) params.set("type", typeSelect.value); + const data = await request(`/__beauticode/ui/gallery/catalog?${params}`); + centerUrl = data.url || centerUrl; + grid.innerHTML = (data.skins || []) + .map( + (skin) => + ``, + ) + .join(""); + msg.textContent = data.skins?.length ? "" : "目录是空的。"; + foot.innerHTML = centerUrl + ? `上传与审核在 皮肤中心网站。安装会下载到本机后再应用。` + : "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。"; + } + + async function open() { + host.hidden = false; + const config = await request("/__beauticode/ui/gallery/config"); + centerUrl = config.url || ""; + if (!config.enabled) { + grid.innerHTML = ""; + msg.textContent = "尚未配置皮肤中心地址。"; + foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。"; + return; + } + await load(); + } + + function close() { + host.hidden = true; + } + + host.querySelector(".bcg-close").addEventListener("click", close); + host.addEventListener("click", (event) => { + if (event.target === host) close(); + }); + queryInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void load(); + } + }); + typeSelect.addEventListener("change", () => void load()); + grid.addEventListener("click", (event) => { + const card = event.target.closest("[data-id]"); + if (!card || busy) return; + const id = card.getAttribute("data-id"); + busy = true; + msg.textContent = "开始安装…"; + request("/__beauticode/ui/gallery/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + }) + .then((result) => { + msg.textContent = result.message || "已安装。"; + document.dispatchEvent(new CustomEvent("beauticode-gallery-installed")); + }) + .catch((error) => { + msg.textContent = error instanceof Error ? error.message : String(error); + }) + .finally(() => { + busy = false; + }); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !host.hidden) { + event.stopPropagation(); + close(); + } + }); + + window.BeauticodeGallery = { open, close }; +})(); diff --git a/integrations/deepseek-harness/host-apply.mjs b/integrations/deepseek-harness/host-apply.mjs index 008a7e5..36e0827 100644 --- a/integrations/deepseek-harness/host-apply.mjs +++ b/integrations/deepseek-harness/host-apply.mjs @@ -1,163 +1,164 @@ -import path from "node:path"; -import { - TRAY_STARTING_MESSAGE, - callDshControl, - readDshControlFile, - readTrayClaim, -} from "./control-client.mjs"; -import { canvasImagePath } from "./presets.mjs"; - +import path from "node:path"; +import { + TRAY_STARTING_MESSAGE, + callDshControl, + readDshControlFile, + readTrayClaim, +} from "./control-client.mjs"; +import { canvasImagePath } from "./presets.mjs"; + const sessions = new Map(); - -export const ENGINE_MISSING_MESSAGE = - "beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。"; - -export function resolvePluginBaseUrl(ctx) { - const port = ctx?.webServer?.port; - if (Number.isInteger(port) && port > 0 && port <= 65535) { - return `http://127.0.0.1:${port}`; - } - const configured = ctx?.webServer?.host; - if (configured && typeof ctx.webServer.port === "number") { - return `http://127.0.0.1:${ctx.webServer.port}`; - } - return "http://127.0.0.1:3080"; -} - -function sessionKey(dataRoot) { - return path.resolve(dataRoot); -} - -export async function loadAdapter() { - const errors = []; - for (const specifier of [ - new URL("./vendor/adapter-dsh/index.js", import.meta.url).href, - "@beauticode/adapter-dsh", - new URL("../../packages/adapter-dsh/dist/index.js", import.meta.url).href, - ]) { - try { - return await import(specifier); - } catch (error) { - errors.push(`${specifier}: ${error instanceof Error ? error.message : String(error)}`); - } - } - const failure = new Error(`${ENGINE_MISSING_MESSAGE}(${errors.join(";")})`); - failure.code = "ENGINE_MISSING"; - throw failure; -} - -export async function hasLiveTray(dataRoot) { - const control = await readDshControlFile(dataRoot); - if (!control) return false; - try { - await callDshControl(dataRoot, { - method: "GET", - path: "/health", - timeoutMs: 2_000, - }); - return true; - } catch { - return false; - } -} - -export async function hasLiveTrayClaim(dataRoot) { - return Boolean(await readTrayClaim(dataRoot)); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function ensureInProcessSession(options) { - const dataRoot = path.resolve(options.dataRoot); - const baseUrl = options.baseUrl || "http://127.0.0.1:3080"; - const key = sessionKey(dataRoot); - const current = sessions.get(key); - if (current?.session && !current.session.isOpen) { - sessions.delete(key); - } else if (current?.session?.isOpen) { - return current.session; - } else if (current?.promise) { - return current.promise; - } - - const promise = (async () => { - if (await hasLiveTray(dataRoot) || await hasLiveTrayClaim(dataRoot)) { - const error = new Error(TRAY_STARTING_MESSAGE); - error.code = "TRAY_CLAIMED"; - throw error; - } - const adapter = await loadAdapter(); - const session = new adapter.DshSession({ - dataRoot, - baseUrl, - verifyDeadlineMs: 30_000, - bundledGalleryImagePath: canvasImagePath() || undefined, - }); - try { - await session.start(); - } catch (error) { - const message = adapter.toChineseErrorMessage(error); - throw new Error(message); - } - const stored = sessions.get(key); - if (stored) stored.session = session; - return session; - })(); - - sessions.set(key, { session: null, promise }); - try { - return await promise; - } catch (error) { - sessions.delete(key); - throw error; - } -} - -export async function stopInProcessSession(dataRoot) { - if (dataRoot == null) { - const all = [...sessions.keys()]; - await Promise.all(all.map((key) => stopInProcessSession(key))); - return; - } - const key = sessionKey(dataRoot); - const current = sessions.get(key); - sessions.delete(key); - if (!current) return; - try { - const session = current.session ?? (await current.promise.catch(() => null)); - if (session) await session.stop(); - } catch { - /* ignore */ - } -} - -export async function resolveApplyBackend(options) { - if (await hasLiveTray(options.dataRoot)) { - return { kind: "tray" }; - } - if (await hasLiveTrayClaim(options.dataRoot)) { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - if (await hasLiveTray(options.dataRoot)) { - return { kind: "tray" }; - } - await sleep(100); - } - if (await hasLiveTrayClaim(options.dataRoot)) { - const error = new Error(TRAY_STARTING_MESSAGE); - error.code = "TRAY_CLAIMED"; - throw error; - } - } - try { - const session = await ensureInProcessSession(options); - return { kind: "local", session }; - } catch (error) { - if (await hasLiveTray(options.dataRoot)) { - return { kind: "tray" }; - } - throw error; - } -} +const DSH_VERIFY_DEADLINE_MS = 10_000; + +export const ENGINE_MISSING_MESSAGE = + "beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。"; + +export function resolvePluginBaseUrl(ctx) { + const port = ctx?.webServer?.port; + if (Number.isInteger(port) && port > 0 && port <= 65535) { + return `http://127.0.0.1:${port}`; + } + const configured = ctx?.webServer?.host; + if (configured && typeof ctx.webServer.port === "number") { + return `http://127.0.0.1:${ctx.webServer.port}`; + } + return "http://127.0.0.1:3080"; +} + +function sessionKey(dataRoot) { + return path.resolve(dataRoot); +} + +export async function loadAdapter() { + const errors = []; + for (const specifier of [ + new URL("./vendor/adapter-dsh/index.js", import.meta.url).href, + "@beauticode/adapter-dsh", + new URL("../../packages/adapter-dsh/dist/index.js", import.meta.url).href, + ]) { + try { + return await import(specifier); + } catch (error) { + errors.push(`${specifier}: ${error instanceof Error ? error.message : String(error)}`); + } + } + const failure = new Error(`${ENGINE_MISSING_MESSAGE}(${errors.join(";")})`); + failure.code = "ENGINE_MISSING"; + throw failure; +} + +export async function hasLiveTray(dataRoot) { + const control = await readDshControlFile(dataRoot); + if (!control) return false; + try { + await callDshControl(dataRoot, { + method: "GET", + path: "/health", + timeoutMs: 2_000, + }); + return true; + } catch { + return false; + } +} + +export async function hasLiveTrayClaim(dataRoot) { + return Boolean(await readTrayClaim(dataRoot)); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function ensureInProcessSession(options) { + const dataRoot = path.resolve(options.dataRoot); + const baseUrl = options.baseUrl || "http://127.0.0.1:3080"; + const key = sessionKey(dataRoot); + const current = sessions.get(key); + if (current?.session && !current.session.isOpen) { + sessions.delete(key); + } else if (current?.session?.isOpen) { + return current.session; + } else if (current?.promise) { + return current.promise; + } + + const promise = (async () => { + if (await hasLiveTray(dataRoot) || await hasLiveTrayClaim(dataRoot)) { + const error = new Error(TRAY_STARTING_MESSAGE); + error.code = "TRAY_CLAIMED"; + throw error; + } + const adapter = await loadAdapter(); + const session = new adapter.DshSession({ + dataRoot, + baseUrl, + verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS, + bundledGalleryImagePath: canvasImagePath() || undefined, + }); + try { + await session.start(); + } catch (error) { + const message = adapter.toChineseErrorMessage(error); + throw new Error(message); + } + const stored = sessions.get(key); + if (stored) stored.session = session; + return session; + })(); + + sessions.set(key, { session: null, promise }); + try { + return await promise; + } catch (error) { + sessions.delete(key); + throw error; + } +} + +export async function stopInProcessSession(dataRoot) { + if (dataRoot == null) { + const all = [...sessions.keys()]; + await Promise.all(all.map((key) => stopInProcessSession(key))); + return; + } + const key = sessionKey(dataRoot); + const current = sessions.get(key); + sessions.delete(key); + if (!current) return; + try { + const session = current.session ?? (await current.promise.catch(() => null)); + if (session) await session.stop(); + } catch { + /* ignore */ + } +} + +export async function resolveApplyBackend(options) { + if (await hasLiveTray(options.dataRoot)) { + return { kind: "tray" }; + } + if (await hasLiveTrayClaim(options.dataRoot)) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (await hasLiveTray(options.dataRoot)) { + return { kind: "tray" }; + } + await sleep(100); + } + if (await hasLiveTrayClaim(options.dataRoot)) { + const error = new Error(TRAY_STARTING_MESSAGE); + error.code = "TRAY_CLAIMED"; + throw error; + } + } + try { + const session = await ensureInProcessSession(options); + return { kind: "local", session }; + } catch (error) { + if (await hasLiveTray(options.dataRoot)) { + return { kind: "tray" }; + } + throw error; + } +} diff --git a/integrations/deepseek-harness/index.mjs b/integrations/deepseek-harness/index.mjs index 2284179..6dd8788 100644 --- a/integrations/deepseek-harness/index.mjs +++ b/integrations/deepseek-harness/index.mjs @@ -1,417 +1,477 @@ -import crypto from "node:crypto"; +import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { registerAgentSurfaces } from "./agent.mjs"; -import { resolvePluginBaseUrl } from "./host-apply.mjs"; -import { createBeauticodeUi } from "./ui-host.mjs"; -import { canvasImagePath, iceFrostImagePath, normalizeAtmosphere } from "./presets.mjs"; - -export const name = "beauticode-bridge"; -export const inject = ["webServer"]; -export const bridgeProtocol = 4; - +import { registerAgentSurfaces } from "./agent.mjs"; +import { resolvePluginBaseUrl } from "./host-apply.mjs"; +import { createBeauticodeUi } from "./ui-host.mjs"; +import { canvasImagePath, iceFrostImagePath, normalizeAtmosphere } from "./presets.mjs"; + +export const name = "beauticode-bridge"; +export const inject = ["webServer"]; +export const bridgeProtocol = 4; + const here = path.dirname(fileURLToPath(import.meta.url)); const TOKEN_PATTERN = /^[a-f0-9]{64}$/; const REVISION_PATTERN = /^[a-f0-9]{64}$/; const MAX_BODY_BYTES = 64 * 1024; - -async function readBridgeIdentity() { - try { - const manifest = JSON.parse( - await fs.readFile(path.join(here, "bridge-manifest.json"), "utf8"), - ); - if ( - manifest.schema === "beauticode.dsh-bridge/v1" && - manifest.protocol === bridgeProtocol && - REVISION_PATTERN.test(manifest.revision) - ) { - return { protocol: bridgeProtocol, revision: manifest.revision }; - } - } catch {} - return { protocol: bridgeProtocol, revision: "source" }; -} - -function defaultTokenFile() { - const base = - process.env.BEAUTICODE_DATA_ROOT || - (process.env.LOCALAPPDATA - ? path.join(process.env.LOCALAPPDATA, "beautiCode") - : path.join(os.homedir(), ".beauticode")); - return path.join(base, "dsh-bridge.token"); -} - -function sendJson(res, status, body) { - const encoded = JSON.stringify(body); - res.writeHead(status, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - "content-length": Buffer.byteLength(encoded), - }); - res.end(encoded); -} - -function readJson(req) { - return new Promise((resolve, reject) => { - const chunks = []; - let size = 0; - req.on("data", (chunk) => { - size += chunk.length; - if (size > MAX_BODY_BYTES) { - const error = new Error("请求内容过大。"); - error.statusCode = 413; - reject(error); - req.removeAllListeners("data"); - req.resume(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - try { - const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - const error = new Error("请求内容必须是 JSON 对象。"); - error.statusCode = 400; - reject(error); - return; - } - resolve(parsed); - } catch (error) { - error.statusCode = 400; - reject(error); - } - }); - req.on("error", reject); - }); -} - -async function authorized(req, tokenFile) { - const match = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization || "").trim()); - if (!match) return false; - let expected; - try { - expected = (await fs.readFile(tokenFile, "utf8")).trim(); - } catch { - return false; - } - if (!TOKEN_PATTERN.test(expected)) return false; - const actualBuffer = Buffer.from(match[1], "utf8"); - const expectedBuffer = Buffer.from(expected, "utf8"); - return ( - actualBuffer.length === expectedBuffer.length && - crypto.timingSafeEqual(actualBuffer, expectedBuffer) - ); -} - -function isSameOrigin(req) { - const origin = req.headers.origin; - if (typeof origin === "string") return origin === `http://${req.headers.host}`; - return req.headers["sec-fetch-site"] === "same-origin"; -} - + +async function readBridgeIdentity() { + try { + const manifest = JSON.parse( + await fs.readFile(path.join(here, "bridge-manifest.json"), "utf8"), + ); + if ( + manifest.schema === "beauticode.dsh-bridge/v1" && + manifest.protocol === bridgeProtocol && + REVISION_PATTERN.test(manifest.revision) + ) { + return { protocol: bridgeProtocol, revision: manifest.revision }; + } + } catch {} + return { protocol: bridgeProtocol, revision: "source" }; +} + +function defaultTokenFile() { + const base = + process.env.BEAUTICODE_DATA_ROOT || + (process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, "beautiCode") + : path.join(os.homedir(), ".beauticode")); + return path.join(base, "dsh-bridge.token"); +} + +function sendJson(res, status, body) { + const encoded = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "content-length": Buffer.byteLength(encoded), + }); + res.end(encoded); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + const error = new Error("请求内容过大。"); + error.statusCode = 413; + reject(error); + req.removeAllListeners("data"); + req.resume(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + const error = new Error("请求内容必须是 JSON 对象。"); + error.statusCode = 400; + reject(error); + return; + } + resolve(parsed); + } catch (error) { + error.statusCode = 400; + reject(error); + } + }); + req.on("error", reject); + }); +} + +async function authorized(req, tokenFile) { + const match = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization || "").trim()); + if (!match) return false; + let expected; + try { + expected = (await fs.readFile(tokenFile, "utf8")).trim(); + } catch { + return false; + } + if (!TOKEN_PATTERN.test(expected)) return false; + const actualBuffer = Buffer.from(match[1], "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + return ( + actualBuffer.length === expectedBuffer.length && + crypto.timingSafeEqual(actualBuffer, expectedBuffer) + ); +} + +function isSameOrigin(req) { + const origin = req.headers.origin; + if (typeof origin === "string") return origin === `http://${req.headers.host}`; + return req.headers["sec-fetch-site"] === "same-origin"; +} + function validLoopbackMediaUrl(value) { - if (typeof value !== "string") return false; - try { - const url = new URL(value); - return ( - url.protocol === "http:" && - ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname.toLowerCase()) && - url.searchParams.has("t") - ); - } catch { - return false; - } + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return ( + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname.toLowerCase()) && + url.searchParams.has("t") + ); + } catch { + return false; + } } function validApplyPayload(body) { - if (!Number.isSafeInteger(body.generation) || body.generation < 0) return false; - if (!["image", "video", "clear"].includes(body.media)) return false; - if (body.atmosphere != null && !normalizeAtmosphere(body.atmosphere)) return false; - if (body.media === "clear") { - return body.imageUrl == null && body.videoUrl == null && body.startAt == null && body.atmosphere == null; - } - if (!validLoopbackMediaUrl(body.imageUrl)) return false; - if (body.media === "image") return body.videoUrl == null && body.startAt == null; - return ( - validLoopbackMediaUrl(body.videoUrl) && - body.atmosphere == null && - (body.startAt == null || (Number.isFinite(body.startAt) && body.startAt >= 0)) - ); -} - -function validTone(value) { - return value === "dark" || value === "light" || value === "auto"; -} - -function publicStatus(current, modes, clients, clientStates) { - const states = [...clientStates.values()]; - const activeAcks = states - .map((state) => state.render) - .filter( - (ack) => - ack && - current && - ack.generation === current.generation && - ack.media === current.media, - ); - const modeAcks = states.map((state) => state.mode).filter(Boolean); - const modeReady = modeAcks.filter( - (ack) => - ack.fish === modes.fish && - ack.tone === modes.tone && - ack.themeSynced === true && - (modes.tone === "auto" || ack.resolvedTone === modes.tone) && - (ack.muted === modes.muted || (modes.muted === false && ack.blocked === true)), - ); - const playback = - activeAcks.find((ack) => ack.ok === true && ack.playback?.hasVideo === true) - ?.playback ?? null; - return { - ok: true, - connectedClients: clients.size, - current, - readyClients: activeAcks.filter((ack) => ack.ok === true).length, - failedClients: activeAcks.filter((ack) => ack.ok !== true).length, - visibleClients: activeAcks.filter((ack) => ack.ok === true && ack.visible === true).length, - modeReadyClients: modeReady.length, - blockedClients: modeReady.filter((ack) => ack.blocked === true).length, - resolvedTone: modeReady[0]?.resolvedTone ?? null, - modes: { ...modes }, - playback, - }; -} - -export function apply(ctx, config = {}) { + if (!Number.isSafeInteger(body.generation) || body.generation < 0) return false; + if (!["image", "video", "clear"].includes(body.media)) return false; + if (body.atmosphere != null && !normalizeAtmosphere(body.atmosphere)) return false; + if (body.media === "clear") { + return body.imageUrl == null && body.videoUrl == null && body.startAt == null && body.atmosphere == null; + } + if (!validLoopbackMediaUrl(body.imageUrl)) return false; + if (body.media === "image") return body.videoUrl == null && body.startAt == null; + return ( + validLoopbackMediaUrl(body.videoUrl) && + body.atmosphere == null && + (body.startAt == null || (Number.isFinite(body.startAt) && body.startAt >= 0)) + ); +} + +function validTone(value) { + return value === "dark" || value === "light" || value === "auto"; +} + +function publicStatus(current, modes, clients, clientStates) { + const states = [...clientStates.values()]; + const activeAcks = states + .map((state) => state.render) + .filter( + (ack) => + ack && + current && + ack.generation === current.generation && + ack.media === current.media, + ); + const modeAcks = states.map((state) => state.mode).filter(Boolean); + const modeReady = modeAcks.filter( + (ack) => + ack.fish === modes.fish && + ack.tone === modes.tone && + ack.themeSynced === true && + (modes.tone === "auto" || ack.resolvedTone === modes.tone) && + (ack.muted === modes.muted || (modes.muted === false && ack.blocked === true)), + ); + const playback = + activeAcks.find((ack) => ack.ok === true && ack.playback?.hasVideo === true) + ?.playback ?? null; + // Only an explicit renderer error is terminal. Older clients may send a + // transient ok:false heartbeat while a large video is still warming up. + const failedAcks = activeAcks.filter( + (ack) => ack.ok === false && typeof ack.error === "string" && ack.error, + ); + return { + ok: true, + connectedClients: clients.size, + current, + readyClients: activeAcks.filter((ack) => ack.ok === true).length, + failedClients: failedAcks.length, + lastRenderError: + failedAcks.find((ack) => typeof ack.error === "string" && ack.error)?.error ?? + null, + visibleClients: activeAcks.filter((ack) => ack.ok === true && ack.visible === true).length, + modeReadyClients: modeReady.length, + blockedClients: modeReady.filter((ack) => ack.blocked === true).length, + resolvedTone: modeReady[0]?.resolvedTone ?? null, + modes: { ...modes }, + playback, + }; +} + +export function apply(ctx, config = {}) { const tokenFile = path.resolve(config.tokenFile || defaultTokenFile()); const clients = new Map(); const clientStates = new Map(); let current = null; - const modes = { fish: false, muted: true, tone: "auto" }; - const dataRoot = path.dirname(tokenFile); - const baseUrl = resolvePluginBaseUrl(ctx); - registerAgentSurfaces(ctx, { dataRoot, baseUrl }); - const ui = createBeauticodeUi({ - dataRoot, - getBaseUrl: () => resolvePluginBaseUrl(ctx), - sendJson, - isSameOrigin, - readJson, - }); - - const broadcast = (payload) => { - const frame = `data: ${JSON.stringify(payload)}\n\n`; - for (const response of clients.values()) response.write(frame); - }; - - ctx.effect(() => { - const disposeTap = ctx.webServer.tapIndex((html) => { - if (html.includes("data-beauticode-bridge")) return html; - const script = - '' + - '' + - ''; - return html.includes("") - ? html.replace("", `${script}`) - : `${html}${script}`; - }); - - const disposers = [ - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/version", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const identity = await readBridgeIdentity(); - if (req.method === "HEAD") { - res.writeHead(200, { "cache-control": "no-store" }).end(); - return; - } - sendJson(res, 200, { ok: true, ...identity }); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/atmosphere.js", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const source = await fs.readFile(path.join(here, "atmosphere.js")); - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-store", - "content-length": source.length, - }); - res.end(req.method === "HEAD" ? undefined : source); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/themes/bg-canvas.png", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const filePath = canvasImagePath(); - if (!filePath) { - res.writeHead(404).end(); - return; - } - const source = await fs.readFile(filePath); - res.writeHead(200, { - "content-type": "image/png", - "cache-control": "public, max-age=86400", - "content-length": source.length, - }); - res.end(req.method === "HEAD" ? undefined : source); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/themes/ice-frost.png", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const filePath = iceFrostImagePath(); - if (!filePath) { - res.writeHead(404).end(); - return; - } - const source = await fs.readFile(filePath); - res.writeHead(200, { - "content-type": "image/png", - "cache-control": "public, max-age=86400", - "content-length": source.length, - }); - res.end(req.method === "HEAD" ? undefined : source); - }, - }), + const modes = { fish: false, muted: true, tone: "auto" }; + const dataRoot = path.dirname(tokenFile); + const baseUrl = resolvePluginBaseUrl(ctx); + registerAgentSurfaces(ctx, { dataRoot, baseUrl }); + const ui = createBeauticodeUi({ + dataRoot, + getBaseUrl: () => resolvePluginBaseUrl(ctx), + sendJson, + isSameOrigin, + readJson, + pickMedia: config.pickMedia, + allowManagedUpload: config.allowManagedUpload, + now: config.now, + selectionTtlMs: config.selectionTtlMs, + }); + + const broadcast = (payload) => { + const frame = `data: ${JSON.stringify(payload)}\n\n`; + for (const response of clients.values()) response.write(frame); + }; + + ctx.effect(() => { + const disposeTap = ctx.webServer.tapIndex((html) => { + if (html.includes("data-beauticode-bridge")) return html; + const script = + '' + + '' + + '' + + ''; + return html.includes("") + ? html.replace("", `${script}`) + : `${html}${script}`; + }); + + const disposers = [ + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/version", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const identity = await readBridgeIdentity(); + if (req.method === "HEAD") { + res.writeHead(200, { "cache-control": "no-store" }).end(); + return; + } + sendJson(res, 200, { ok: true, ...identity }); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/atmosphere.js", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const source = await fs.readFile(path.join(here, "atmosphere.js")); + res.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/themes/bg-canvas.png", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const filePath = canvasImagePath(); + if (!filePath) { + res.writeHead(404).end(); + return; + } + const source = await fs.readFile(filePath); + res.writeHead(200, { + "content-type": "image/png", + "cache-control": "public, max-age=86400", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/themes/ice-frost.png", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const filePath = iceFrostImagePath(); + if (!filePath) { + res.writeHead(404).end(); + return; + } + const source = await fs.readFile(filePath); + res.writeHead(200, { + "content-type": "image/png", + "cache-control": "public, max-age=86400", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), ctx.webServer.register({ kind: "exact", path: "/__beauticode/client.js", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const source = await fs.readFile(path.join(here, "client.js")); - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-store", - "content-length": source.length, - }); + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const source = await fs.readFile(path.join(here, "client.js")); + res.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": source.length, + }); res.end(req.method === "HEAD" ? undefined : source); }, }), ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/console.js", - handler: async (req, res) => { - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end(); - return; - } - const source = await fs.readFile(path.join(here, "console.js")); - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-store", - "content-length": source.length, - }); - res.end(req.method === "HEAD" ? undefined : source); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/status", - handler: (req, res) => ui.status(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/import", - handler: (req, res) => ui.importFile(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/clear", - handler: (req, res) => ui.clear(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/mode", - handler: (req, res) => ui.mode(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/theme/use", - handler: (req, res) => ui.useTheme(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ui/preset", - handler: (req, res) => ui.applyPreset(req, res), - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/events", - handler: (req, res) => { - if (req.method !== "GET" || !isSameOrigin(req)) { - res.writeHead(req.method === "GET" ? 403 : 405).end(); - return; - } - const url = new URL(req.url || "/", `http://${req.headers.host}`); - const clientId = url.searchParams.get("clientId"); - if (!clientId || !/^[A-Za-z0-9._-]{8,80}$/.test(clientId)) { - res.writeHead(400).end(); - return; - } - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - res.write(": connected\n\n"); - clients.get(clientId)?.destroy(); - clients.set(clientId, res); - clientStates.set(clientId, { render: null, mode: null }); + kind: "exact", + path: "/__beauticode/gallery.js", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const source = await fs.readFile(path.join(here, "gallery.js")); + res.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/console.js", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const source = await fs.readFile(path.join(here, "console.js")); + res.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/status", + handler: (req, res) => ui.status(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/import", + handler: (req, res) => ui.importFile(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/pick", + handler: (req, res) => ui.pickMedia(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/import-selected", + handler: (req, res) => ui.importSelected(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/clear", + handler: (req, res) => ui.clear(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/mode", + handler: (req, res) => ui.mode(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/theme/use", + handler: (req, res) => ui.useTheme(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/theme/delete", + handler: (req, res) => ui.deleteTheme(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/preset", + handler: (req, res) => ui.applyPreset(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/gallery/config", + handler: (req, res) => ui.galleryConfig(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/gallery/catalog", + handler: (req, res) => ui.galleryCatalog(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/gallery/install", + handler: (req, res) => ui.galleryInstall(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/events", + handler: (req, res) => { + if (req.method !== "GET" || !isSameOrigin(req)) { + res.writeHead(req.method === "GET" ? 403 : 405).end(); + return; + } + const url = new URL(req.url || "/", `http://${req.headers.host}`); + const clientId = url.searchParams.get("clientId"); + if (!clientId || !/^[A-Za-z0-9._-]{8,80}$/.test(clientId)) { + res.writeHead(400).end(); + return; + } + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(": connected\n\n"); + clients.get(clientId)?.destroy(); + clients.set(clientId, res); + clientStates.set(clientId, { render: null, mode: null }); if (current) { res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`); - } - res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`); - ui.scheduleRestore(); - res.on("close", () => { - if (clients.get(clientId) === res) { - clients.delete(clientId); - clientStates.delete(clientId); - } - }); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/apply", - handler: async (req, res) => { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!(await authorized(req, tokenFile))) { - sendJson(res, 401, { ok: false, error: "未授权的请求。" }); - return; - } - const body = await readJson(req); - if (!validApplyPayload(body)) { - sendJson(res, 400, { ok: false, error: "背景载荷无效。" }); - return; - } + } + res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`); + ui.scheduleRestore(); + res.on("close", () => { + if (clients.get(clientId) === res) { + clients.delete(clientId); + clientStates.delete(clientId); + } + }); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/apply", + handler: async (req, res) => { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!(await authorized(req, tokenFile))) { + sendJson(res, 401, { ok: false, error: "未授权的请求。" }); + return; + } + const body = await readJson(req); + if (!validApplyPayload(body)) { + sendJson(res, 400, { ok: false, error: "背景载荷无效。" }); + return; + } current = { generation: body.generation, media: body.media, @@ -424,147 +484,147 @@ export function apply(ctx, config = {}) { if (body.media === "clear") modes.fish = false; for (const state of clientStates.values()) state.render = null; broadcast({ type: "apply", ...current }); - if (body.media === "clear") broadcast({ type: "mode", ...modes }); - sendJson(res, 200, { ok: true }); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/mode", - handler: async (req, res) => { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!(await authorized(req, tokenFile))) { - sendJson(res, 401, { ok: false, error: "未授权的请求。" }); - return; - } - const body = await readJson(req); - const keys = Object.keys(body); - if ( - keys.length === 0 || - keys.some((key) => !["fish", "muted", "tone"].includes(key)) || - ("fish" in body && typeof body.fish !== "boolean") || - ("muted" in body && typeof body.muted !== "boolean") || - ("tone" in body && !validTone(body.tone)) - ) { - sendJson(res, 400, { ok: false, error: "显示模式载荷无效。" }); - return; - } - if (typeof body.fish === "boolean") modes.fish = body.fish; - if (typeof body.muted === "boolean") modes.muted = body.muted; - if (validTone(body.tone)) modes.tone = body.tone; - for (const state of clientStates.values()) state.mode = null; - broadcast({ type: "mode", ...modes }); - sendJson(res, 200, { ok: true, modes: { ...modes } }); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/status", - handler: async (req, res) => { - if (req.method !== "GET") { - res.writeHead(405).end(); - return; - } - if (!(await authorized(req, tokenFile))) { - sendJson(res, 401, { ok: false, error: "未授权的请求。" }); - return; - } - sendJson(res, 200, publicStatus(current, modes, clients, clientStates)); - }, - }), - ctx.webServer.register({ - kind: "exact", - path: "/__beauticode/ack", - handler: async (req, res) => { - if (req.method !== "POST" || !isSameOrigin(req)) { - res.writeHead(req.method === "POST" ? 403 : 405).end(); - return; - } - const body = await readJson(req); - const state = clientStates.get(body.clientId); - if (!clients.has(body.clientId) || !state) { - sendJson(res, 400, { ok: false, error: "渲染回执无效。" }); - return; - } - if (body.kind === "render") { - if ( - !current || - body.generation !== current.generation || - body.media !== current.media || - typeof body.ok !== "boolean" || - typeof body.visible !== "boolean" - ) { - sendJson(res, 400, { ok: false, error: "渲染回执无效。" }); - return; - } - let playback = null; - if (body.playback?.hasVideo === true) { - if ( - !Number.isFinite(body.playback.currentTime) || - !Number.isFinite(body.playback.duration) || - typeof body.playback.muted !== "boolean" || - typeof body.playback.paused !== "boolean" || - typeof body.playback.blocked !== "boolean" - ) { - sendJson(res, 400, { ok: false, error: "播放状态回执无效。" }); - return; - } - playback = { - currentTime: Math.max(0, body.playback.currentTime), - duration: Math.max(0, body.playback.duration), - hasVideo: true, - muted: body.playback.muted, - paused: body.playback.paused, - blocked: body.playback.blocked, - }; - } - state.render = { - generation: body.generation, - media: body.media, - ok: body.ok, - visible: body.visible, - error: typeof body.error === "string" ? body.error.slice(0, 300) : null, - playback, - }; - } else if (body.kind === "mode") { - if ( - typeof body.fish !== "boolean" || - typeof body.muted !== "boolean" || - !validTone(body.tone) || - !["dark", "light"].includes(body.resolvedTone) || - typeof body.themeSynced !== "boolean" || - typeof body.blocked !== "boolean" - ) { - sendJson(res, 400, { ok: false, error: "显示模式回执无效。" }); - return; - } - state.mode = { - fish: body.fish, - muted: body.muted, - tone: body.tone, - resolvedTone: body.resolvedTone, - themeSynced: body.themeSynced, - blocked: body.blocked, - }; - } else { - sendJson(res, 400, { ok: false, error: "回执类型无效。" }); - return; - } - sendJson(res, 200, { ok: true }); - }, - }), - ]; - - return () => { - disposeTap(); - for (const dispose of disposers) dispose(); + if (body.media === "clear") broadcast({ type: "mode", ...modes }); + sendJson(res, 200, { ok: true }); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/mode", + handler: async (req, res) => { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!(await authorized(req, tokenFile))) { + sendJson(res, 401, { ok: false, error: "未授权的请求。" }); + return; + } + const body = await readJson(req); + const keys = Object.keys(body); + if ( + keys.length === 0 || + keys.some((key) => !["fish", "muted", "tone"].includes(key)) || + ("fish" in body && typeof body.fish !== "boolean") || + ("muted" in body && typeof body.muted !== "boolean") || + ("tone" in body && !validTone(body.tone)) + ) { + sendJson(res, 400, { ok: false, error: "显示模式载荷无效。" }); + return; + } + if (typeof body.fish === "boolean") modes.fish = body.fish; + if (typeof body.muted === "boolean") modes.muted = body.muted; + if (validTone(body.tone)) modes.tone = body.tone; + for (const state of clientStates.values()) state.mode = null; + broadcast({ type: "mode", ...modes }); + sendJson(res, 200, { ok: true, modes: { ...modes } }); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/status", + handler: async (req, res) => { + if (req.method !== "GET") { + res.writeHead(405).end(); + return; + } + if (!(await authorized(req, tokenFile))) { + sendJson(res, 401, { ok: false, error: "未授权的请求。" }); + return; + } + sendJson(res, 200, publicStatus(current, modes, clients, clientStates)); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ack", + handler: async (req, res) => { + if (req.method !== "POST" || !isSameOrigin(req)) { + res.writeHead(req.method === "POST" ? 403 : 405).end(); + return; + } + const body = await readJson(req); + const state = clientStates.get(body.clientId); + if (!clients.has(body.clientId) || !state) { + sendJson(res, 400, { ok: false, error: "渲染回执无效。" }); + return; + } + if (body.kind === "render") { + if ( + !current || + body.generation !== current.generation || + body.media !== current.media || + typeof body.ok !== "boolean" || + typeof body.visible !== "boolean" + ) { + sendJson(res, 400, { ok: false, error: "渲染回执无效。" }); + return; + } + let playback = null; + if (body.playback?.hasVideo === true) { + if ( + !Number.isFinite(body.playback.currentTime) || + !Number.isFinite(body.playback.duration) || + typeof body.playback.muted !== "boolean" || + typeof body.playback.paused !== "boolean" || + typeof body.playback.blocked !== "boolean" + ) { + sendJson(res, 400, { ok: false, error: "播放状态回执无效。" }); + return; + } + playback = { + currentTime: Math.max(0, body.playback.currentTime), + duration: Math.max(0, body.playback.duration), + hasVideo: true, + muted: body.playback.muted, + paused: body.playback.paused, + blocked: body.playback.blocked, + }; + } + state.render = { + generation: body.generation, + media: body.media, + ok: body.ok, + visible: body.visible, + error: typeof body.error === "string" ? body.error.slice(0, 300) : null, + playback, + }; + } else if (body.kind === "mode") { + if ( + typeof body.fish !== "boolean" || + typeof body.muted !== "boolean" || + !validTone(body.tone) || + !["dark", "light"].includes(body.resolvedTone) || + typeof body.themeSynced !== "boolean" || + typeof body.blocked !== "boolean" + ) { + sendJson(res, 400, { ok: false, error: "显示模式回执无效。" }); + return; + } + state.mode = { + fish: body.fish, + muted: body.muted, + tone: body.tone, + resolvedTone: body.resolvedTone, + themeSynced: body.themeSynced, + blocked: body.blocked, + }; + } else { + sendJson(res, 400, { ok: false, error: "回执类型无效。" }); + return; + } + sendJson(res, 200, { ok: true }); + }, + }), + ]; + + return () => { + disposeTap(); + for (const dispose of disposers) dispose(); for (const response of clients.values()) response.destroy(); clients.clear(); clientStates.clear(); void ui.dispose(); - }; - }, "beauticode-bridge: routes and browser injection"); -} + }; + }, "beauticode-bridge: routes and browser injection"); +} diff --git a/integrations/deepseek-harness/package.json b/integrations/deepseek-harness/package.json index 403ca8d..152fcf9 100644 --- a/integrations/deepseek-harness/package.json +++ b/integrations/deepseek-harness/package.json @@ -1,56 +1,59 @@ -{ - "name": "@beauticode/dsh-plugin", - "version": "1.0.0", - "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.", - "type": "module", - "main": "./index.mjs", - "bin": { - "beauticode-dsh": "bin/beauticode-dsh" - }, - "exports": { - ".": { - "import": "./index.mjs" - } - }, - "files": [ - "index.mjs", - "client.js", - "console.js", - "atmosphere.js", - "presets.mjs", - "agent.mjs", - "control-client.mjs", - "host-apply.mjs", - "ui-host.mjs", - "cli.js", - "bin/beauticode-dsh", - "cordis.patch.yml", - "vendor", - "themes" - ], - "dsh": { - "bundle": { - "patch": "./cordis.patch.yml" - } - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "dsh", - "dsh-plugin", - "deepseek", - "deepseek-harness", - "background", - "wallpaper" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/starsstreaming/beautiCode.git", - "directory": "integrations/deepseek-harness" - }, - "engines": { - "node": ">=22" - }, - "license": "MIT" -} +{ + "name": "beauticode-dsh", + "version": "1.0.19", + "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.", + "type": "module", + "main": "./index.mjs", + "bin": { + "beauticode-dsh": "bin/beauticode-dsh" + }, + "exports": { + ".": { + "import": "./index.mjs" + } + }, + "files": [ + "index.mjs", + "client.js", + "console.js", + "gallery.js", + "gallery-host.mjs", + "skin-center.json", + "atmosphere.js", + "presets.mjs", + "agent.mjs", + "control-client.mjs", + "host-apply.mjs", + "ui-host.mjs", + "cli.js", + "bin/beauticode-dsh", + "cordis.patch.yml", + "vendor", + "themes" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "dsh", + "dsh-plugin", + "deepseek", + "deepseek-harness", + "background", + "wallpaper" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/starsstreaming/beautiCode.git", + "directory": "integrations/deepseek-harness" + }, + "engines": { + "node": ">=22" + }, + "license": "MIT" +} diff --git a/integrations/deepseek-harness/skin-center.json b/integrations/deepseek-harness/skin-center.json new file mode 100644 index 0000000..fe0f269 --- /dev/null +++ b/integrations/deepseek-harness/skin-center.json @@ -0,0 +1,3 @@ +{ + "url": "https://hnnulwh.cn" +} diff --git a/integrations/deepseek-harness/test/agent.test.mjs b/integrations/deepseek-harness/test/agent.test.mjs index 165fe1b..b66ce8e 100644 --- a/integrations/deepseek-harness/test/agent.test.mjs +++ b/integrations/deepseek-harness/test/agent.test.mjs @@ -1,488 +1,549 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { apply } from "../index.mjs"; -import { - createBeauticodeActions, - registerAgentSurfaces, - runBgCommand, -} from "../agent.mjs"; -import { resolveApplyBackend, stopInProcessSession } from "../host-apply.mjs"; -import { - CONTROL_FILE, - CONTROL_SCHEMA, - TRAY_MISSING_MESSAGE, - TRAY_STARTING_MESSAGE, - callDshControl, - inspectLocalMedia, - isLoopbackControlUrl, - matchSavedTheme, - readDshControlFile, - readSessionHostFile, - readTrayClaim, - removeDshControlFile, - removeSessionHostFile, - removeTrayClaim, - stripPathQuotes, - writeDshControlFile, - writeSessionHostFile, - writeTrayClaim, -} from "../control-client.mjs"; - -const TOKEN = "control-token-for-agent-tests-123456"; -const PNG_1X1 = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", -); - -function mp4Fixture() { - const fileTypeBox = Buffer.alloc(24); - fileTypeBox.writeUInt32BE(24, 0); - fileTypeBox.write("ftyp", 4, "ascii"); - fileTypeBox.write("isom", 8, "ascii"); - return fileTypeBox; -} - -async function createTempRoot() { - return fs.mkdtemp(path.join(os.tmpdir(), "beauticode-agent-")); -} - -async function writeControl(root, url, token = TOKEN) { - await writeDshControlFile({ - dataRoot: root, - url, - token, - pid: process.pid, - }); -} - -function json(res, status, body) { - const encoded = JSON.stringify(body); - res.writeHead(status, { "content-type": "application/json" }); - res.end(encoded); -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); -} - -async function startFakeTray(handler) { - const received = []; - const server = http.createServer(async (req, res) => { - const authorization = String(req.headers.authorization || ""); - if (authorization !== `Bearer ${TOKEN}`) { - json(res, 401, { ok: false, error: "请求未授权。" }); - return; - } - const url = req.url?.split("?")[0] ?? ""; - let body = {}; - if (req.method !== "GET") body = await readBody(req); - received.push({ method: req.method, url, body }); - try { - await handler({ method: req.method, url, body }, res); - } catch (error) { - json(res, 500, { ok: false, error: String(error.message || error) }); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - return { - received, - url: `http://127.0.0.1:${server.address().port}`, - close: () => new Promise((resolve) => server.close(resolve)), - }; -} - -test("loopback control URLs are accepted and others are rejected", () => { - assert.equal(isLoopbackControlUrl("http://127.0.0.1:4123"), true); - assert.equal(isLoopbackControlUrl("http://localhost:4123"), true); - assert.equal(isLoopbackControlUrl("http://[::1]:4123"), true); - assert.equal(isLoopbackControlUrl("http://192.168.1.8:4123"), false); - assert.equal(isLoopbackControlUrl("https://127.0.0.1:4123"), false); - assert.equal(isLoopbackControlUrl("http://127.0.0.1:4123/apply"), false); -}); - -test("path quotes and theme matching", () => { - assert.equal(stripPathQuotes(' "D:\\\\a b.mp4" '), "D:\\\\a b.mp4"); - assert.equal(stripPathQuotes("'C:\\\\x.png'"), "C:\\\\x.png"); - const themes = [ - { id: "aaa", name: "雨夜写代码" }, - { id: "bbb", name: "海边下午" }, - ]; - assert.equal(matchSavedTheme(themes, "aaa").theme.id, "aaa"); - assert.equal(matchSavedTheme(themes, "海边下午").theme.id, "bbb"); - assert.equal(matchSavedTheme(themes, "雨夜").theme.id, "aaa"); - assert.match(matchSavedTheme(themes, "不存在").error, /未找到主题/); - assert.match(matchSavedTheme(themes, "").error, /必须提供/); -}); - -test("inspectLocalMedia requires an absolute regular file", async (t) => { - const root = await createTempRoot(); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const image = path.join(root, "poster.png"); - const video = path.join(root, "clip.mp4"); - await fs.writeFile(image, PNG_1X1); - await fs.writeFile(video, mp4Fixture()); - await fs.writeFile(path.join(root, "notes.txt"), "nope"); - - assert.equal((await inspectLocalMedia("relative.mp4")).ok, false); - assert.equal((await inspectLocalMedia(image)).kind, "image"); - assert.equal((await inspectLocalMedia(`"${video}"`)).kind, "video"); - assert.match((await inspectLocalMedia(path.join(root, "missing.mp4"))).error, /找不到文件/); - assert.match((await inspectLocalMedia(path.join(root, "notes.txt"))).error ?? "", /只支持/); -}); - -test("control file is written atomically and ignored when the pid is dead", async (t) => { - const root = await createTempRoot(); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const file = await writeDshControlFile({ - dataRoot: root, - url: "http://127.0.0.1:34567", - token: TOKEN, - pid: process.pid, - }); - assert.equal(path.basename(file), CONTROL_FILE); - const live = await readDshControlFile(root); - assert.equal(live.schema, CONTROL_SCHEMA); - assert.equal(live.url, "http://127.0.0.1:34567"); - assert.equal(live.token, TOKEN); - - await writeDshControlFile({ - dataRoot: root, - url: "http://127.0.0.1:34567", - token: TOKEN, - pid: 2_147_483_647, - }); - assert.equal(await readDshControlFile(root), null); - const dead = await readDshControlFile(root, { allowDead: true }); - assert.equal(dead.pid, 2_147_483_647); - - assert.equal(await removeDshControlFile({ dataRoot: root, pid: process.pid }), false); - assert.equal(await removeDshControlFile({ dataRoot: root, pid: 2_147_483_647 }), true); -}); - -test("callDshControl requires a live tray and rejects non-loopback files", async (t) => { - const root = await createTempRoot(); - t.after(() => fs.rm(root, { recursive: true, force: true })); - await assert.rejects( - () => callDshControl(root, { method: "GET", path: "/status" }), - (error) => error.message === TRAY_MISSING_MESSAGE, - ); - await fs.writeFile( - path.join(root, CONTROL_FILE), - JSON.stringify({ - schema: CONTROL_SCHEMA, - host: "dsh", - pid: process.pid, - url: "http://192.168.1.8:9", - token: TOKEN, - }), - ); - assert.equal(await readDshControlFile(root), null); -}); - -test("tools and slash commands reuse the tray apply routes", async (t) => { - const root = await createTempRoot(); - const image = path.join(root, "wall.png"); - const video = path.join(root, "bg.mp4"); - await fs.writeFile(image, PNG_1X1); - await fs.writeFile(video, mp4Fixture()); - const themes = [{ id: "theme-rain", name: "雨夜写代码", type: "video" }]; - const tray = await startFakeTray(({ url, body }, res) => { - if (url === "/health") { - json(res, 200, { ok: true, open: true, hostReady: true }); - return; - } - if (url === "/apply/image") { - json(res, 200, { ok: true, generation: 4, mode: "image" }); - return; - } - if (url === "/apply/video") { - json(res, 200, { ok: true, generation: 5, mode: "video" }); - return; - } - if (url === "/apply/clear") { - json(res, 200, { ok: true, generation: 6, mode: "clear" }); - return; - } - if (url === "/status") { - json(res, 200, { - ok: true, - hostReady: true, - sessions: 1, - fish: false, - muted: true, - tone: "dark", - manifest: { background: { type: "video" } }, - }); - return; - } - if (url === "/theme/list") { - json(res, 200, { ok: true, themes }); - return; - } - if (url === "/theme/use") { - json(res, 200, { ok: true, generation: 7, mode: "video" }); - return; - } - if (url === "/mode/fish") { - json(res, 200, { ok: true, fish: body.enabled === true }); - return; - } - json(res, 404, { ok: false, error: "未找到请求的资源。" }); - }); - t.after(async () => { - await tray.close(); - await fs.rm(root, { recursive: true, force: true }); - }); - await writeControl(root, tray.url); - - const actions = createBeauticodeActions(root); - assert.equal((await actions.applyImage(image)).message, "已将图片设为背景。"); - assert.equal((await actions.applyVideo({ path: video, startAt: 12 })).ok, true); - assert.equal((await actions.useTheme("雨夜")).theme.id, "theme-rain"); - assert.equal((await actions.setFish(true)).fish, true); - assert.match(await runBgCommand(root, ""), /背景:视频/); - assert.equal(await runBgCommand(root, `"${image}"`), "已将图片设为背景。"); - - const videoApply = tray.received.find((item) => item.url === "/apply/video"); - assert.equal(videoApply.body.videoPath, video); - assert.equal(videoApply.body.startAt, 12); - const themeUse = tray.received.find((item) => item.url === "/theme/use"); - assert.equal(themeUse.body.id, "theme-rain"); - - await assert.rejects( - () => actions.applyVideo({ path: image }), - /只接受 \.mp4/, - ); -}); - -test("plugin registers tools and commands through optional inject", async (t) => { - const root = await createTempRoot(); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const tools = []; - const commands = []; - const injected = []; - const ctx = { - inject(deps, callback) { - injected.push(deps); - callback({ - tools: { - register(definition) { - tools.push(definition); - return () => {}; - }, - }, - commands: { - register(definition) { - commands.push(definition); - return () => {}; - }, - }, - get() { - return undefined; - }, - }); - }, - }; - registerAgentSurfaces(ctx, { dataRoot: root }); - assert.deepEqual(injected, [["tools"], ["commands"]]); - assert.ok(tools.some((tool) => tool.name === "beauticode_apply_video")); - assert.ok(tools.some((tool) => tool.name === "beauticode_theme_use")); - assert.deepEqual( - commands.map((command) => command.name), - ["bg", "bg-theme", "bg-clear"], - ); - assert.equal(typeof tools[0].output.render, "function"); - assert.ok(Number.isFinite(tools[0].timeoutMs)); -}); - -test("empty /bg works without a tray by starting the in-process session", async (t) => { - const root = await createTempRoot(); - const options = { dataRoot: root, baseUrl: "http://127.0.0.1:1" }; - t.after(async () => { - await stopInProcessSession(root); - await fs.rm(root, { recursive: true, force: true }); - }); - const text = await runBgCommand(options, " "); - assert.match(text, /背景:无/); - assert.match(text, /\/bg-theme/); -}); - -test("in-process apply imports a video without the tray", async (t) => { - const root = await createTempRoot(); - const dataRoot = path.join(root, "data"); - const image = path.join(root, "wall.png"); - const video = path.join(root, "bg.mp4"); - await fs.writeFile(image, PNG_1X1); - await fs.writeFile(video, mp4Fixture()); - - let current = null; - let modes = { fish: false, muted: true, tone: "dark" }; - const server = http.createServer(async (req, res) => { - const authorization = String(req.headers.authorization || ""); - if (!/^Bearer [a-f0-9]{64}$/.test(authorization)) { - json(res, 401, { ok: false, error: "unauthorized" }); - return; - } - if (req.url === "/__beauticode/apply" && req.method === "POST") { - current = await readBody(req); - json(res, 200, { ok: true }); - return; - } - if (req.url === "/__beauticode/mode" && req.method === "POST") { - modes = { ...modes, ...(await readBody(req)) }; - json(res, 200, { ok: true, modes }); - return; - } - if (req.url === "/__beauticode/status" && req.method === "GET") { - json(res, 200, { - ok: true, - connectedClients: 1, - current, - readyClients: current ? 1 : 0, - failedClients: 0, - visibleClients: current && current.media !== "clear" ? 1 : 0, - modeReadyClients: 1, - blockedClients: 0, - resolvedTone: "dark", - modes, - playback: null, - }); - return; - } - json(res, 404, { ok: false }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const baseUrl = `http://127.0.0.1:${server.address().port}`; - t.after(async () => { - await stopInProcessSession(dataRoot); - await new Promise((resolve) => server.close(resolve)); - await fs.rm(root, { recursive: true, force: true }); - }); - - const actions = createBeauticodeActions({ dataRoot, baseUrl }); - const applied = await actions.applyVideo({ path: video, poster: image }); - assert.equal(applied.ok, true); - assert.equal(applied.mode, "video"); - assert.equal(current.media, "video"); - assert.match(String(current.videoUrl), /^http:\/\/127\.0\.0\.1:\d+\//); - const status = await actions.status(); - assert.equal(status.background?.type, "video"); -}); - -test("prompt section failure does not prevent tool registration", () => { - const tools = []; - registerAgentSurfaces( - { - inject(deps, callback) { - if (!deps.includes("tools")) return; - callback({ - tools: { - register(definition) { - tools.push(definition); - return () => {}; - }, - }, - get() { - throw new Error("systemPrompt unavailable"); - }, - }); - }, - }, - { dataRoot: os.tmpdir() }, - ); - assert.ok(tools.some((tool) => tool.name === "beauticode_apply_video")); -}); - -test("page bridge still loads when inject is absent", async (t) => { - const root = await createTempRoot(); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, "b".repeat(64)); - const routes = new Map(); - apply( - { - webServer: { - register(route) { - routes.set(route.path, route.handler); - return () => routes.delete(route.path); - }, - tapIndex() { - return () => {}; - }, - }, - effect(factory) { - return factory(); - }, - }, - { tokenFile }, - ); - t.after(() => fs.rm(root, { recursive: true, force: true })); - assert.ok(routes.has("/__beauticode/apply")); - assert.ok(routes.has("/__beauticode/version")); -}); - -test("tray claim and session-host files ignore dead pids", async (t) => { - const root = await createTempRoot(); - t.after(async () => { - await fs.rm(root, { recursive: true, force: true }); - }); - await writeTrayClaim({ dataRoot: root, pid: process.pid }); - const liveClaim = await readTrayClaim(root); - assert.equal(liveClaim.pid, process.pid); - await writeSessionHostFile({ - dataRoot: root, - host: "dsh", - url: "http://127.0.0.1:9", - token: TOKEN, - pid: process.pid, - }); - const liveHost = await readSessionHostFile(root); - assert.equal(liveHost.host, "dsh"); - assert.equal(liveHost.pid, process.pid); - assert.equal(await removeTrayClaim({ dataRoot: root, pid: process.pid }), true); - assert.equal(await readTrayClaim(root), null); - assert.equal(await removeSessionHostFile({ dataRoot: root, pid: process.pid }), true); - assert.equal(await readSessionHostFile(root), null); -}); - -test("resolveApplyBackend waits for a tray claim to become a live tray", async (t) => { - const root = await createTempRoot(); - const tray = await startFakeTray(async ({ url }, res) => { - if (url === "/health") { - json(res, 200, { ok: true }); - return; - } - json(res, 404, { ok: false }); - }); - t.after(async () => { - await tray.close(); - await fs.rm(root, { recursive: true, force: true }); - }); - await writeTrayClaim({ dataRoot: root, pid: process.pid }); - setTimeout(() => { - void writeControl(root, tray.url); - }, 150); - const backend = await resolveApplyBackend({ - dataRoot: root, - baseUrl: "http://127.0.0.1:1", - }); - assert.equal(backend.kind, "tray"); -}); - -test("resolveApplyBackend refuses in-process start while a live tray claim remains", async (t) => { - const root = await createTempRoot(); - t.after(async () => { - await stopInProcessSession(root); - await fs.rm(root, { recursive: true, force: true }); - }); - await writeTrayClaim({ dataRoot: root, pid: process.pid }); - await assert.rejects( - () => resolveApplyBackend({ dataRoot: root, baseUrl: "http://127.0.0.1:1" }), - (error) => error.message === TRAY_STARTING_MESSAGE && error.code === "TRAY_CLAIMED", - ); -}); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { apply } from "../index.mjs"; +import { + createBeauticodeActions, + registerAgentSurfaces, + runBgCommand, +} from "../agent.mjs"; +import { resolveApplyBackend, stopInProcessSession } from "../host-apply.mjs"; +import { + CONTROL_FILE, + CONTROL_SCHEMA, + TRAY_MISSING_MESSAGE, + TRAY_STARTING_MESSAGE, + callDshControl, + inspectLocalMedia, + isLoopbackControlUrl, + matchSavedTheme, + readDshControlFile, + readSessionHostFile, + readTrayClaim, + removeDshControlFile, + removeSessionHostFile, + removeTrayClaim, + stripPathQuotes, + writeDshControlFile, + writeSessionHostFile, + writeTrayClaim, +} from "../control-client.mjs"; + +const TOKEN = "control-token-for-agent-tests-123456"; +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +function mp4Fixture() { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(24, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + return fileTypeBox; +} + +async function createTempRoot() { + return fs.mkdtemp(path.join(os.tmpdir(), "beauticode-agent-")); +} + +async function writeControl(root, url, token = TOKEN) { + await writeDshControlFile({ + dataRoot: root, + url, + token, + pid: process.pid, + }); +} + +function json(res, status, body) { + const encoded = JSON.stringify(body); + res.writeHead(status, { "content-type": "application/json" }); + res.end(encoded); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); +} + +async function startFakeTray(handler) { + const received = []; + const server = http.createServer(async (req, res) => { + const authorization = String(req.headers.authorization || ""); + if (authorization !== `Bearer ${TOKEN}`) { + json(res, 401, { ok: false, error: "请求未授权。" }); + return; + } + const url = req.url?.split("?")[0] ?? ""; + let body = {}; + if (req.method !== "GET") body = await readBody(req); + received.push({ method: req.method, url, body }); + try { + await handler({ method: req.method, url, body }, res); + } catch (error) { + json(res, 500, { ok: false, error: String(error.message || error) }); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + received, + url: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +test("loopback control URLs are accepted and others are rejected", () => { + assert.equal(isLoopbackControlUrl("http://127.0.0.1:4123"), true); + assert.equal(isLoopbackControlUrl("http://localhost:4123"), true); + assert.equal(isLoopbackControlUrl("http://[::1]:4123"), true); + assert.equal(isLoopbackControlUrl("http://192.168.1.8:4123"), false); + assert.equal(isLoopbackControlUrl("https://127.0.0.1:4123"), false); + assert.equal(isLoopbackControlUrl("http://127.0.0.1:4123/apply"), false); +}); + +test("path quotes and theme matching", () => { + assert.equal(stripPathQuotes(' "D:\\\\a b.mp4" '), "D:\\\\a b.mp4"); + assert.equal(stripPathQuotes("'C:\\\\x.png'"), "C:\\\\x.png"); + const themes = [ + { id: "aaa", name: "雨夜写代码" }, + { id: "bbb", name: "海边下午" }, + ]; + assert.equal(matchSavedTheme(themes, "aaa").theme.id, "aaa"); + assert.equal(matchSavedTheme(themes, "海边下午").theme.id, "bbb"); + assert.equal(matchSavedTheme(themes, "雨夜").theme.id, "aaa"); + assert.match(matchSavedTheme(themes, "不存在").error, /未找到主题/); + assert.match(matchSavedTheme(themes, "").error, /必须提供/); +}); + +test("inspectLocalMedia requires an absolute regular file", async (t) => { + const root = await createTempRoot(); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const image = path.join(root, "poster.png"); + const video = path.join(root, "clip.mp4"); + await fs.writeFile(image, PNG_1X1); + await fs.writeFile(video, mp4Fixture()); + await fs.writeFile(path.join(root, "notes.txt"), "nope"); + + assert.equal((await inspectLocalMedia("relative.mp4")).ok, false); + assert.equal((await inspectLocalMedia(image)).kind, "image"); + assert.equal((await inspectLocalMedia(`"${video}"`)).kind, "video"); + assert.match((await inspectLocalMedia(path.join(root, "missing.mp4"))).error, /找不到文件/); + assert.match((await inspectLocalMedia(path.join(root, "notes.txt"))).error ?? "", /只支持/); +}); + +test("control file is written atomically and ignored when the pid is dead", async (t) => { + const root = await createTempRoot(); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = await writeDshControlFile({ + dataRoot: root, + url: "http://127.0.0.1:34567", + token: TOKEN, + pid: process.pid, + }); + assert.equal(path.basename(file), CONTROL_FILE); + const live = await readDshControlFile(root); + assert.equal(live.schema, CONTROL_SCHEMA); + assert.equal(live.url, "http://127.0.0.1:34567"); + assert.equal(live.token, TOKEN); + + await writeDshControlFile({ + dataRoot: root, + url: "http://127.0.0.1:34567", + token: TOKEN, + pid: 2_147_483_647, + }); + assert.equal(await readDshControlFile(root), null); + const dead = await readDshControlFile(root, { allowDead: true }); + assert.equal(dead.pid, 2_147_483_647); + + assert.equal(await removeDshControlFile({ dataRoot: root, pid: process.pid }), false); + assert.equal(await removeDshControlFile({ dataRoot: root, pid: 2_147_483_647 }), true); +}); + +test("callDshControl requires a live tray and rejects non-loopback files", async (t) => { + const root = await createTempRoot(); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await assert.rejects( + () => callDshControl(root, { method: "GET", path: "/status" }), + (error) => error.message === TRAY_MISSING_MESSAGE, + ); + await fs.writeFile( + path.join(root, CONTROL_FILE), + JSON.stringify({ + schema: CONTROL_SCHEMA, + host: "dsh", + pid: process.pid, + url: "http://192.168.1.8:9", + token: TOKEN, + }), + ); + assert.equal(await readDshControlFile(root), null); +}); + +test("tools and slash commands reuse the tray apply routes", async (t) => { + const root = await createTempRoot(); + const image = path.join(root, "wall.png"); + const video = path.join(root, "bg.mp4"); + await fs.writeFile(image, PNG_1X1); + await fs.writeFile(video, mp4Fixture()); + const themes = [{ id: "theme-rain", name: "雨夜写代码", type: "video" }]; + const tray = await startFakeTray(({ url, body }, res) => { + if (url === "/health") { + json(res, 200, { ok: true, open: true, hostReady: true }); + return; + } + if (url === "/apply/image") { + json(res, 200, { ok: true, generation: 4, mode: "image" }); + return; + } + if (url === "/apply/video") { + json(res, 200, { ok: true, generation: 5, mode: "video" }); + return; + } + if (url === "/theme/apply") { + const type = body.input.type; + json(res, 200, { + ok: true, + generation: type === "video" ? 5 : 4, + mode: type, + theme: { + id: `theme-${type}`, + name: body.name, + type, + }, + }); + return; + } + if (url === "/apply/clear") { + json(res, 200, { ok: true, generation: 6, mode: "clear" }); + return; + } + if (url === "/status") { + json(res, 200, { + ok: true, + hostReady: true, + sessions: 1, + fish: false, + muted: true, + tone: "dark", + manifest: { background: { type: "video" } }, + }); + return; + } + if (url === "/theme/list") { + json(res, 200, { ok: true, themes }); + return; + } + if (url === "/theme/use") { + json(res, 200, { ok: true, generation: 7, mode: "video" }); + return; + } + if (url === "/mode/fish") { + json(res, 200, { ok: true, fish: body.enabled === true }); + return; + } + json(res, 404, { ok: false, error: "未找到请求的资源。" }); + }); + t.after(async () => { + await tray.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await writeControl(root, tray.url); + + const actions = createBeauticodeActions(root); + assert.equal((await actions.applyImage(image)).message, "已将「wall」设为背景。"); + assert.equal((await actions.applyVideo({ path: video, startAt: 12 })).ok, true); + assert.equal((await actions.useTheme("雨夜")).theme.id, "theme-rain"); + assert.equal((await actions.setFish(true)).fish, true); + assert.match(await runBgCommand(root, ""), /背景:视频/); + assert.equal(await runBgCommand(root, `"${image}"`), "已将「wall」设为背景。"); + + const videoApply = tray.received.find( + (item) => item.url === "/theme/apply" && item.body.input.type === "video", + ); + assert.equal(videoApply.body.name, "bg"); + assert.equal(videoApply.body.input.videoPath, video); + assert.equal(videoApply.body.input.startAt, 12); + assert.equal(videoApply.body.input.source, "local"); + const themeUse = tray.received.find((item) => item.url === "/theme/use"); + assert.equal(themeUse.body.id, "theme-rain"); + + await assert.rejects( + () => actions.applyVideo({ path: image }), + /只接受 \.mp4/, + ); +}); + +test("failed apply preserves source mode and phase timings for diagnostics", async (t) => { + const root = await createTempRoot(); + const image = path.join(root, "wall.png"); + await fs.writeFile(image, PNG_1X1); + const tray = await startFakeTray(({ url }, res) => { + if (url === "/health") { + json(res, 200, { ok: true, open: true, hostReady: true }); + return; + } + if (url === "/theme/apply") { + json(res, 200, { + ok: false, + error: "renderer failed", + sourceMode: "local", + timings: { totalMs: 1400, phases: { rendererVerify: 1200, rollback: 30 } }, + }); + return; + } + json(res, 404, { ok: false, error: "not found" }); + }); + t.after(async () => { + await tray.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await writeControl(root, tray.url); + + await assert.rejects( + () => createBeauticodeActions(root).applyImage(image), + (error) => { + assert.equal(error.sourceMode, "local"); + assert.equal(error.timings.phases.rendererVerify, 1200); + assert.equal(error.timings.phases.rollback, 30); + return true; + }, + ); +}); + +test("plugin registers tools and commands through optional inject", async (t) => { + const root = await createTempRoot(); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const tools = []; + const commands = []; + const injected = []; + const ctx = { + inject(deps, callback) { + injected.push(deps); + callback({ + tools: { + register(definition) { + tools.push(definition); + return () => {}; + }, + }, + commands: { + register(definition) { + commands.push(definition); + return () => {}; + }, + }, + get() { + return undefined; + }, + }); + }, + }; + registerAgentSurfaces(ctx, { dataRoot: root }); + assert.deepEqual(injected, [["tools"], ["commands"]]); + assert.ok(tools.some((tool) => tool.name === "beauticode_apply_video")); + assert.ok(tools.some((tool) => tool.name === "beauticode_theme_use")); + assert.deepEqual( + commands.map((command) => command.name), + ["bg", "bg-theme", "bg-clear"], + ); + assert.equal(typeof tools[0].output.render, "function"); + assert.ok(Number.isFinite(tools[0].timeoutMs)); +}); + +test("empty /bg works without a tray by starting the in-process session", async (t) => { + const root = await createTempRoot(); + const options = { dataRoot: root, baseUrl: "http://127.0.0.1:1" }; + t.after(async () => { + await stopInProcessSession(root); + await fs.rm(root, { recursive: true, force: true }); + }); + const text = await runBgCommand(options, " "); + assert.match(text, /背景:无/); + assert.match(text, /\/bg-theme/); +}); + +test("in-process apply imports a video without the tray", async (t) => { + const root = await createTempRoot(); + const dataRoot = path.join(root, "data"); + const image = path.join(root, "wall.png"); + const video = path.join(root, "bg.mp4"); + await fs.writeFile(image, PNG_1X1); + await fs.writeFile(video, mp4Fixture()); + + let current = null; + let modes = { fish: false, muted: true, tone: "dark" }; + const server = http.createServer(async (req, res) => { + const authorization = String(req.headers.authorization || ""); + if (!/^Bearer [a-f0-9]{64}$/.test(authorization)) { + json(res, 401, { ok: false, error: "unauthorized" }); + return; + } + if (req.url === "/__beauticode/apply" && req.method === "POST") { + current = await readBody(req); + json(res, 200, { ok: true }); + return; + } + if (req.url === "/__beauticode/mode" && req.method === "POST") { + modes = { ...modes, ...(await readBody(req)) }; + json(res, 200, { ok: true, modes }); + return; + } + if (req.url === "/__beauticode/status" && req.method === "GET") { + json(res, 200, { + ok: true, + connectedClients: 1, + current, + readyClients: current ? 1 : 0, + failedClients: 0, + visibleClients: current && current.media !== "clear" ? 1 : 0, + modeReadyClients: 1, + blockedClients: 0, + resolvedTone: "dark", + modes, + playback: null, + }); + return; + } + json(res, 404, { ok: false }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + t.after(async () => { + await stopInProcessSession(dataRoot); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }); + + const actions = createBeauticodeActions({ dataRoot, baseUrl }); + const applied = await actions.applyVideo({ path: video, poster: image }); + assert.equal(applied.ok, true); + assert.equal(applied.mode, "video"); + assert.equal(current.media, "video"); + assert.match(String(current.videoUrl), /^http:\/\/127\.0\.0\.1:\d+\//); + const status = await actions.status(); + assert.equal(status.background?.type, "video"); + assert.equal(status.background?.source?.kind, "local"); + assert.equal(status.background?.video, undefined); + assert.deepEqual((await fs.readdir(path.join(dataRoot, "active"))).sort(), [ + "background.json", + "poster.png", + ]); +}); + +test("prompt section failure does not prevent tool registration", () => { + const tools = []; + registerAgentSurfaces( + { + inject(deps, callback) { + if (!deps.includes("tools")) return; + callback({ + tools: { + register(definition) { + tools.push(definition); + return () => {}; + }, + }, + get() { + throw new Error("systemPrompt unavailable"); + }, + }); + }, + }, + { dataRoot: os.tmpdir() }, + ); + assert.ok(tools.some((tool) => tool.name === "beauticode_apply_video")); +}); + +test("page bridge still loads when inject is absent", async (t) => { + const root = await createTempRoot(); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, "b".repeat(64)); + const routes = new Map(); + apply( + { + webServer: { + register(route) { + routes.set(route.path, route.handler); + return () => routes.delete(route.path); + }, + tapIndex() { + return () => {}; + }, + }, + effect(factory) { + return factory(); + }, + }, + { tokenFile }, + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + assert.ok(routes.has("/__beauticode/apply")); + assert.ok(routes.has("/__beauticode/version")); +}); + +test("tray claim and session-host files ignore dead pids", async (t) => { + const root = await createTempRoot(); + t.after(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + await writeTrayClaim({ dataRoot: root, pid: process.pid }); + const liveClaim = await readTrayClaim(root); + assert.equal(liveClaim.pid, process.pid); + await writeSessionHostFile({ + dataRoot: root, + host: "dsh", + url: "http://127.0.0.1:9", + token: TOKEN, + pid: process.pid, + }); + const liveHost = await readSessionHostFile(root); + assert.equal(liveHost.host, "dsh"); + assert.equal(liveHost.pid, process.pid); + assert.equal(await removeTrayClaim({ dataRoot: root, pid: process.pid }), true); + assert.equal(await readTrayClaim(root), null); + assert.equal(await removeSessionHostFile({ dataRoot: root, pid: process.pid }), true); + assert.equal(await readSessionHostFile(root), null); +}); + +test("resolveApplyBackend waits for a tray claim to become a live tray", async (t) => { + const root = await createTempRoot(); + const tray = await startFakeTray(async ({ url }, res) => { + if (url === "/health") { + json(res, 200, { ok: true }); + return; + } + json(res, 404, { ok: false }); + }); + t.after(async () => { + await tray.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await writeTrayClaim({ dataRoot: root, pid: process.pid }); + setTimeout(() => { + void writeControl(root, tray.url); + }, 150); + const backend = await resolveApplyBackend({ + dataRoot: root, + baseUrl: "http://127.0.0.1:1", + }); + assert.equal(backend.kind, "tray"); +}); + +test("resolveApplyBackend refuses in-process start while a live tray claim remains", async (t) => { + const root = await createTempRoot(); + t.after(async () => { + await stopInProcessSession(root); + await fs.rm(root, { recursive: true, force: true }); + }); + await writeTrayClaim({ dataRoot: root, pid: process.pid }); + await assert.rejects( + () => resolveApplyBackend({ dataRoot: root, baseUrl: "http://127.0.0.1:1" }), + (error) => error.message === TRAY_STARTING_MESSAGE && error.code === "TRAY_CLAIMED", + ); +}); diff --git a/integrations/deepseek-harness/test/atmosphere.test.mjs b/integrations/deepseek-harness/test/atmosphere.test.mjs index 8caca93..8b55e07 100644 --- a/integrations/deepseek-harness/test/atmosphere.test.mjs +++ b/integrations/deepseek-harness/test/atmosphere.test.mjs @@ -1,38 +1,39 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import test from "node:test"; -import vm from "node:vm"; -import { fileURLToPath } from "node:url"; -import { canvasImagePath, normalizeAtmosphere } from "../presets.mjs"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const atmosphereSource = fs.readFileSync(path.join(here, "../atmosphere.js"), "utf8"); -const atmosphereSandbox = { BeauticodeAtmosphere: null }; -vm.runInNewContext(atmosphereSource, atmosphereSandbox); -const atmosphere = atmosphereSandbox.BeauticodeAtmosphere; - -test("gallery canvas asset is present", () => { - const filePath = canvasImagePath(); - assert.ok(fs.existsSync(filePath)); - assert.match(filePath, /bg-canvas-4k\.png$/); -}); - -test("water sim rises under a poke so mouse follow can drive ripples", () => { - const water = atmosphere.createWaterSim(64, 36); - const before = water.heights()[18 * 64 + 32]; - water.poke(32, 18, 2, 3); - const after = water.heights()[18 * 64 + 32]; - assert.ok(after > before); - water.step(0.033); - assert.equal(water.heights().length, 64 * 36); -}); - -test("gallery is a valid atmosphere preset", () => { - assert.equal(normalizeAtmosphere({ preset: "gallery" }).preset, "gallery"); -}); - +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { canvasImagePath, normalizeAtmosphere } from "../presets.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const atmosphereSource = fs.readFileSync(path.join(here, "../atmosphere.js"), "utf8"); +const atmosphereSandbox = { BeauticodeAtmosphere: null }; +vm.runInNewContext(atmosphereSource, atmosphereSandbox); +const atmosphere = atmosphereSandbox.BeauticodeAtmosphere; + +test("gallery canvas asset is present", () => { + const filePath = canvasImagePath(); + assert.ok(fs.existsSync(filePath)); + assert.match(filePath, /bg-canvas-4k\.png$/); +}); + +test("water sim rises under a poke so mouse follow can drive ripples", () => { + const water = atmosphere.createWaterSim(64, 36); + const before = water.heights()[18 * 64 + 32]; + water.poke(32, 18, 2, 3); + const after = water.heights()[18 * 64 + 32]; + assert.ok(after > before); + water.step(0.033); + assert.equal(water.heights().length, 64 * 36); +}); + +test("gallery is a valid atmosphere preset", () => { + assert.equal(normalizeAtmosphere({ preset: "gallery" }).preset, "gallery"); +}); + test("atmosphere.js stays a valid browser script", () => { const source = fs.readFileSync(path.join(here, "../atmosphere.js"), "utf8"); assert.doesNotThrow(() => new Function(source)); + assert.match(source, /#beauticode-bg-stage img,\s*\nhtml\[data-bc-gallery="true"\] #beauticode-bg-stage video/); }); diff --git a/integrations/deepseek-harness/test/pack.test.mjs b/integrations/deepseek-harness/test/pack.test.mjs index 33b7200..3722e54 100644 --- a/integrations/deepseek-harness/test/pack.test.mjs +++ b/integrations/deepseek-harness/test/pack.test.mjs @@ -1,42 +1,44 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { stageDshPlugin } from "../../../scripts/pack-dsh-plugin.mjs"; -import { runCli } from "../cli.js"; - -const here = path.dirname(fileURLToPath(import.meta.url)); - -test("staged npm plugin is a self-contained DSH bundle with a vendored engine", async () => { - const dest = path.join(os.tmpdir(), `bc-dsh-pack-${process.pid}`); - await stageDshPlugin(dest, { build: false }); - const pkg = JSON.parse(await fs.readFile(path.join(dest, "package.json"), "utf8")); - assert.equal(pkg.name, "@beauticode/dsh-plugin"); - assert.equal(pkg.dsh.bundle.patch, "./cordis.patch.yml"); - assert.equal(pkg.bin["beauticode-dsh"], "bin/beauticode-dsh"); - assert.equal(await fs.readFile(path.join(dest, "cordis.patch.yml"), "utf8").then((text) => text.includes("beauticode-bridge")), true); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { stageDshPlugin } from "../../../scripts/pack-dsh-plugin.mjs"; +import { runCli } from "../cli.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +test("staged npm plugin is a self-contained DSH bundle with a vendored engine", async () => { + const dest = path.join(os.tmpdir(), `bc-dsh-pack-${process.pid}`); + await stageDshPlugin(dest, { build: false }); + const pkg = JSON.parse(await fs.readFile(path.join(dest, "package.json"), "utf8")); + assert.equal(pkg.name, "beauticode-dsh"); + assert.equal(pkg.dsh.bundle.patch, "./cordis.patch.yml"); + assert.equal(pkg.bin["beauticode-dsh"], "bin/beauticode-dsh"); + assert.equal(await fs.readFile(path.join(dest, "cordis.patch.yml"), "utf8").then((text) => text.includes("beauticode-bridge")), true); const adapter = path.join(dest, "vendor", "adapter-dsh", "index.js"); const canvas = path.join(dest, "themes", "internal-beyond", "bg-canvas-4k.png"); + const license = path.join(dest, "LICENSE"); await fs.access(adapter); await fs.access(canvas); + assert.match(await fs.readFile(license, "utf8"), /MIT License/); const session = await import(pathToFileURL(adapter).href); - assert.equal(typeof session.DshSession, "function"); - await fs.rm(dest, { recursive: true, force: true }); -}); - -test("npx installer writes a DSH home patch without a web profile", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-dsh-npx-")); - const dshHome = path.join(root, "dsh"); - const pluginHome = path.join(root, "plugin"); - await runCli(["--dsh-home", dshHome, "--plugin-home", pluginHome]); - const patch = await fs.readFile(path.join(dshHome, "cordis.patch.yml"), "utf8"); - assert.match(patch, /id: beauticode-bridge/); - assert.match(patch, /file:/); - await fs.access(path.join(pluginHome, "index.mjs")); - await fs.access(path.join(pluginHome, "vendor", "adapter-dsh", "index.js")); - await runCli(["--remove", "--dsh-home", dshHome, "--plugin-home", pluginHome]); - await assert.rejects(() => fs.access(pluginHome)); - await fs.rm(root, { recursive: true, force: true }); -}); + assert.equal(typeof session.DshSession, "function"); + await fs.rm(dest, { recursive: true, force: true }); +}); + +test("npx installer writes a DSH home patch without a web profile", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-dsh-npx-")); + const dshHome = path.join(root, "dsh"); + const pluginHome = path.join(root, "plugin"); + await runCli(["--dsh-home", dshHome, "--plugin-home", pluginHome]); + const patch = await fs.readFile(path.join(dshHome, "cordis.patch.yml"), "utf8"); + assert.match(patch, /id: beauticode-bridge/); + assert.match(patch, /file:/); + await fs.access(path.join(pluginHome, "index.mjs")); + await fs.access(path.join(pluginHome, "vendor", "adapter-dsh", "index.js")); + await runCli(["--remove", "--dsh-home", dshHome, "--plugin-home", pluginHome]); + await assert.rejects(() => fs.access(pluginHome)); + await fs.rm(root, { recursive: true, force: true }); +}); diff --git a/integrations/deepseek-harness/test/plugin.test.mjs b/integrations/deepseek-harness/test/plugin.test.mjs index 8cf6e00..7cf5804 100644 --- a/integrations/deepseek-harness/test/plugin.test.mjs +++ b/integrations/deepseek-harness/test/plugin.test.mjs @@ -1,411 +1,1438 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import vm from "node:vm"; -import { apply } from "../index.mjs"; - -const TOKEN = "b".repeat(64); - -class FakeWebServer { - routes = new Map(); - taps = []; - - register(route) { - this.routes.set(route.path, route.handler); - return () => this.routes.delete(route.path); - } - - tapIndex(tap) { - this.taps.push(tap); - return () => this.taps.splice(this.taps.indexOf(tap), 1); - } -} - -async function createPluginServer(tokenFile) { - const webServer = new FakeWebServer(); - const effects = []; - apply( - { - webServer, - effect(factory) { - effects.push(factory()); - }, - }, - { tokenFile }, +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { apply } from "../index.mjs"; + +const TOKEN = "b".repeat(64); + +class FakeWebServer { + routes = new Map(); + taps = []; + + register(route) { + this.routes.set(route.path, route.handler); + return () => this.routes.delete(route.path); + } + + tapIndex(tap) { + this.taps.push(tap); + return () => this.taps.splice(this.taps.indexOf(tap), 1); + } +} + +async function createPluginServer(tokenFile) { + const webServer = new FakeWebServer(); + const effects = []; + apply( + { + webServer, + effect(factory) { + effects.push(factory()); + }, + }, + { tokenFile }, + ); + const server = http.createServer(async (req, res) => { + const pathname = new URL(req.url || "/", "http://x").pathname; + const handler = webServer.routes.get(pathname); + if (!handler) return res.writeHead(404).end(); + try { + await handler(req, res); + } catch (error) { + res.writeHead(error.statusCode || 500).end(String(error.message || error)); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + return { + webServer, + origin: `http://127.0.0.1:${port}`, + dispose: async () => { + for (const effect of effects.reverse()) await effect?.(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +function openEvents(origin, clientId) { + return new Promise((resolve, reject) => { + const request = http.get( + `${origin}/__beauticode/events?clientId=${clientId}`, + { headers: { "Sec-Fetch-Site": "same-origin" } }, + (response) => { + let data = ""; + response.on("data", (chunk) => { + data += chunk; + if (data.includes(": connected")) resolve({ request, response, read: () => data }); + }); + }, + ); + request.on("error", reject); + }); +} + +test("plugin injects its client script exactly once", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + const tap = plugin.webServer.taps[0]; + const once = tap(""); + const twice = tap(once); + assert.equal((twice.match(/data-beauticode-bridge/g) || []).length, 1); + assert.match(once, /__beauticode\/atmosphere\.js/); + assert.match(once, /__beauticode\/console\.js/); + const atmosphere = await fetch(`${plugin.origin}/__beauticode/atmosphere.js`); + assert.equal(atmosphere.status, 200); + const atmosphereSource = await atmosphere.text(); + assert.doesNotThrow(() => new Function(atmosphereSource)); + const response = await fetch(`${plugin.origin}/__beauticode/client.js`); + assert.equal(response.status, 200); + const source = await response.text(); + assert.doesNotThrow(() => new Function(source)); + assert.match(source, /waitForStablePlayback/); + assert.match( + source, + /if \(reusable && payload\.media === "video"\)[\s\S]*?await waitForStablePlayback\(reusableVideo, signal, Math\.min\(750, remaining\(\)\)\)/, ); - const server = http.createServer(async (req, res) => { - const pathname = new URL(req.url || "/", "http://x").pathname; - const handler = webServer.routes.get(pathname); - if (!handler) return res.writeHead(404).end(); - try { - await handler(req, res); - } catch (error) { - res.writeHead(error.statusCode || 500).end(String(error.message || error)); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const port = server.address().port; - return { - webServer, - origin: `http://127.0.0.1:${port}`, - dispose: async () => { - for (const effect of effects.reverse()) await effect?.(); - await new Promise((resolve) => server.close(resolve)); - }, - }; -} - -function openEvents(origin, clientId) { - return new Promise((resolve, reject) => { - const request = http.get( - `${origin}/__beauticode/events?clientId=${clientId}`, - { headers: { "Sec-Fetch-Site": "same-origin" } }, - (response) => { - let data = ""; - response.on("data", (chunk) => { - data += chunk; - if (data.includes(": connected")) resolve({ request, response, read: () => data }); - }); - }, - ); - request.on("error", reject); - }); -} - -test("plugin injects its client script exactly once", async (t) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, TOKEN); - const plugin = await createPluginServer(tokenFile); - t.after(async () => { - await plugin.dispose(); - await fs.rm(root, { recursive: true, force: true }); - }); - const tap = plugin.webServer.taps[0]; - const once = tap(""); - const twice = tap(once); - assert.equal((twice.match(/data-beauticode-bridge/g) || []).length, 1); - assert.match(once, /__beauticode\/atmosphere\.js/); - assert.match(once, /__beauticode\/console\.js/); - const atmosphere = await fetch(`${plugin.origin}/__beauticode/atmosphere.js`); - assert.equal(atmosphere.status, 200); - const atmosphereSource = await atmosphere.text(); - assert.doesNotThrow(() => new Function(atmosphereSource)); - const response = await fetch(`${plugin.origin}/__beauticode/client.js`); - assert.equal(response.status, 200); - const source = await response.text(); - assert.doesNotThrow(() => new Function(source)); - assert.match(source, /:has\(#root \[data-phase="active"\]\)/); - assert.match(source, /:has\(#root \[data-phase="settling"\]\)/); - assert.doesNotMatch(source, /:has\(#root \[data-phase="hero"\]\)/); - assert.match(source, /#beauticode-bg-stage::after\{background:rgba\(0,0,0,\.42\)\}/); - assert.match(source, /\[class\*=\"_fade\"\]\{display:none!important\}/); - assert.match(source, /data-bc-resolved-tone/); - assert.match(source, /data-ds-dark-theme/); - assert.doesNotMatch(source, /toggleAttribute\("data-ds-dark-theme"/); - assert.match(source, /prefers-color-scheme: dark/); + const playWithPreferenceSource = source.match( + /async function playWithPreference\([\s\S]*?\n \}/, + )?.[0]; + assert.ok(playWithPreferenceSource); + assert.doesNotMatch(playWithPreferenceSource, /playbackBlocked = blocked/); + assert.match(playWithPreferenceSource, /return blocked/); + assert.match(source, /playbackBlocked = await playWithPreference\(video\)/); + assert.match( + source, + /remainingMs\(\) > CROSSFADE_MS \+ FRAME_FALLBACK_MS \* 2 \+ 250/, + ); + assert.match(source, /renderPhase = "pending"/); + assert.match(source, /renderPhase === "ready"/); + assert.doesNotMatch(source, /acknowledgeRender\(activePayload, video\.readyState >= 2/); + assert.match(source, /requestVideoFrameCallback/); + assert.match(source, /VIDEO_FIRST_FRAME_PROGRESS_SEC = 0\.03/); + assert.match(source, /VIDEO_STABLE_FRAMES = 3/); + assert.match(source, /VIDEO_STABLE_PROGRESS_SEC = 0\.18/); + assert.match(source, /VIDEO_PROBE_TIMEOUT_MS = 2_000/); + assert.match(source, /Range: "bytes=0-1"/); + assert.match(source, /视频媒体不可达或被 CORS 拒绝/); + assert.match( + source, + /video\.load\(\);[\s\S]*?const sourceProbe = probeVideoSource\([\s\S]*?await Promise\.all\(\[\s*sourceProbe,/, + ); + assert.match(source, /await waitForPresentedFrame\(video, signal, remaining\(\)\)/); + assert.match(source, /const nextStartAt = seekVideo\(reusableVideo, normalizedStartAt\)/); + assert.match(source, /currentSlot\.dataset\.bcImageUrl = payload\.imageUrl/); + assert.match(source, /img\{z-index:2;opacity:1\}/); + assert.match(source, /video\{z-index:1;opacity:1\}/); + assert.doesNotMatch(source, /video\{z-index:1;opacity:\.001\}/); + assert.match(source, /addEventListener\("canplay"/); + assert.match(source, /addEventListener\("playing"/); + assert.match(source, /addEventListener\("waiting"/); + assert.match(source, /addEventListener\("stalled"/); + assert.match(source, /addEventListener\("pause", resetStableWindow\)/); + assert.match(source, /addEventListener\("seeking", resetStableWindow\)/); + assert.match(source, /data-bc-transitioning/); + assert.match(source, /function disposeVideo\(/); + assert.match(source, /new AbortController\(\)/); + assert.doesNotMatch(source, /replaceChildren\(image, video\)/); + assert.match(source, /MEDIA_ERR_DECODE/); + assert.match(source, /:has\(#root \[data-phase="active"\]\)/); + assert.match(source, /:has\(#root \[data-phase="settling"\]\)/); + assert.doesNotMatch(source, /:has\(#root \[data-phase="hero"\]\)/); + assert.match(source, /#beauticode-bg-stage::after\{background:rgba\(0,0,0,\.42\)\}/); + assert.match(source, /\[class\*=\"_fade\"\]\{display:none!important\}/); + assert.match(source, /data-bc-resolved-tone/); + assert.match(source, /data-ds-dark-theme/); + assert.doesNotMatch(source, /toggleAttribute\("data-ds-dark-theme"/); + assert.match(source, /prefers-color-scheme: dark/); assert.match(source, /new MutationObserver\(scheduleDshThemeSync\)/); assert.match(source, /function dshStructureIssue\(\)/); + assert.match(source, /CLIENT_APPLY_DEADLINE_MS = 8_000/); + assert.match(source, /DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS/); + assert.match(source, /function waitForDshStructure\(/); assert.match(source, /function syncGallery\(/); assert.match(source, /preset === "gallery"/); assert.match(source, /未找到 #root/); - - const version = await fetch(`${plugin.origin}/__beauticode/version`); - assert.deepEqual(await version.json(), { - ok: true, - protocol: 4, - revision: "source", - }); - assert.equal( - (await fetch(`${plugin.origin}/__beauticode/version`, { method: "HEAD" })).status, - 200, - ); - assert.equal( - (await fetch(`${plugin.origin}/__beauticode/version`, { method: "POST" })).status, - 405, + const hostApplySource = await fs.readFile( + new URL("../host-apply.mjs", import.meta.url), + "utf8", ); + assert.match(hostApplySource, /const DSH_VERIFY_DEADLINE_MS = 10_000/); + assert.match(hostApplySource, /verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS/); + + const version = await fetch(`${plugin.origin}/__beauticode/version`); + assert.deepEqual(await version.json(), { + ok: true, + protocol: 4, + revision: "source", + }); + assert.equal( + (await fetch(`${plugin.origin}/__beauticode/version`, { method: "HEAD" })).status, + 200, + ); + assert.equal( + (await fetch(`${plugin.origin}/__beauticode/version`, { method: "POST" })).status, + 405, + ); +}); + +test("browser client follows DSH appearance and does not overwrite it", async () => { + const source = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); + const attributes = new Set(); + const body = { + hasAttribute: (name) => attributes.has(name), + toggleAttribute(name, force) { + if (force) attributes.add(name); + else attributes.delete(name); + }, + prepend() {}, + }; + const documentElement = { + dataset: {}, + style: { colorScheme: "light" }, + removeAttribute(name) { + if (name === "data-bc-fish") delete this.dataset.bcFish; + }, + }; + const media = { + matches: true, + listener: null, + addEventListener(_name, listener) { + this.listener = listener; + }, + }; + let events; + let observerCallback; + const context = { + crypto: { randomUUID: () => "client-theme-test" }, + document: { + body, + documentElement, + head: { append() {} }, + createElement: () => ({ dataset: {}, style: {} }), + getElementById: () => null, + querySelector: () => null, + }, + fetch: async () => ({ ok: true }), + HTMLMediaElement: { HAVE_CURRENT_DATA: 2 }, + HTMLVideoElement: class {}, + Image: class {}, + matchMedia: () => media, + MutationObserver: class { + constructor(callback) { + observerCallback = callback; + } + observe() {} + }, + EventSource: class { + constructor() { + events = this; + } + }, + queueMicrotask: (callback) => callback(), + setInterval: () => 0, + }; + context.window = context; + context.globalThis = context; + vm.runInNewContext(source, context); + + assert.equal(documentElement.dataset.bcResolvedTone, "light"); + assert.equal(documentElement.style.colorScheme, "light"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), false); + + attributes.add("data-ds-dark-theme"); + documentElement.style.colorScheme = "dark"; + observerCallback(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(documentElement.dataset.bcResolvedTone, "dark"); + assert.equal(documentElement.style.colorScheme, "dark"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), true); + + attributes.delete("data-ds-dark-theme"); + documentElement.style.colorScheme = "light"; + events.onmessage({ + data: JSON.stringify({ type: "mode", fish: false, muted: true, tone: "dark" }), + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(documentElement.dataset.bcResolvedTone, "light"); + assert.equal(documentElement.style.colorScheme, "light"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), false); }); -test("browser client follows DSH appearance and does not overwrite it", async () => { - const source = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); - const attributes = new Set(); - const body = { - hasAttribute: (name) => attributes.has(name), - toggleAttribute(name, force) { - if (force) attributes.add(name); - else attributes.delete(name); - }, - prepend() {}, - }; - const documentElement = { - dataset: {}, - style: { colorScheme: "light" }, - removeAttribute(name) { - if (name === "data-bc-fish") delete this.dataset.bcFish; - }, - }; - const media = { - matches: true, - listener: null, - addEventListener(_name, listener) { - this.listener = listener; - }, - }; - let events; - let observerCallback; +test("browser client probes video Range access and explains CORS failures", async () => { + const originalSource = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); + const source = originalSource.replace( + " function describeVideoState(video, phase) {", + ` globalThis.__testProbeVideoSource = probeVideoSource; + + function describeVideoState(video, phase) {`, + ); + assert.notEqual(source, originalSource, "test must expose the video probe"); + + const body = { hasAttribute: () => false, prepend() {} }; + const documentElement = { dataset: {}, style: { colorScheme: "light" } }; + let fetchImpl = null; + const calls = []; const context = { - crypto: { randomUUID: () => "client-theme-test" }, + AbortController, + DOMException, + TypeError, + URL, + crypto: { randomUUID: () => "client-video-probe-test" }, document: { body, documentElement, head: { append() {} }, + visibilityState: "visible", createElement: () => ({ dataset: {}, style: {} }), getElementById: () => null, - querySelector: () => null, }, - fetch: async () => ({ ok: true }), + fetch: async (url, options) => { + calls.push({ url, options }); + return fetchImpl(url, options); + }, HTMLMediaElement: { HAVE_CURRENT_DATA: 2 }, HTMLVideoElement: class {}, Image: class {}, - matchMedia: () => media, - MutationObserver: class { - constructor(callback) { - observerCallback = callback; + location: { href: "http://localhost:3080/" }, + matchMedia: () => ({ matches: false, addEventListener() {} }), + MutationObserver: class { observe() {} }, + EventSource: class {}, + queueMicrotask, + setInterval: () => 0, + setTimeout, + clearTimeout, + }; + context.window = context; + context.globalThis = context; + vm.runInNewContext(source, context); + + fetchImpl = async () => ({ + status: 206, + arrayBuffer: async () => new Uint8Array([0, 0]).buffer, + body: { cancel: async () => {} }, + }); + const controller = new AbortController(); + await context.__testProbeVideoSource( + "http://127.0.0.1:45678/media/token?t=token", + controller.signal, + 100, + ); + assert.equal(calls[0].options.headers.Range, "bytes=0-1"); + assert.equal(calls[0].options.mode, "cors"); + assert.equal(calls[0].options.credentials, "omit"); + + fetchImpl = async () => { + throw new TypeError("Failed to fetch"); + }; + await assert.rejects( + () => + context.__testProbeVideoSource( + "http://127.0.0.1:45678/media/token?t=token", + controller.signal, + 100, + ), + /视频媒体不可达或被 CORS 拒绝;页面Origin=http:\/\/localhost:3080;媒体Origin=http:\/\/127\.0\.0\.1:45678/, + ); +}); + +test("browser client retries a mounted image candidate after a silent first attempt", async () => { + const originalSource = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); + const source = originalSource + .replace("const CLIENT_APPLY_DEADLINE_MS = 8_000;", "const CLIENT_APPLY_DEADLINE_MS = 250;") + .replace("const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;", "const IMAGE_ATTEMPT_TIMEOUT_MS = 15;"); + assert.notEqual(source, originalSource, "test must shorten the client image timeouts"); + + const dataKey = (attribute) => + attribute + .slice(5) + .replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + let documentElement; + + class FakeElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentElement = null; + this.dataset = {}; + this.style = {}; + this.className = ""; + this.id = ""; + this.attributes = new Map(); + } + + get isConnected() { + let node = this; + while (node.parentElement) node = node.parentElement; + return node === documentElement; + } + + append(...nodes) { + for (const node of nodes) { + node.remove(); + node.parentElement = this; + this.children.push(node); + } + } + + prepend(...nodes) { + for (const node of [...nodes].reverse()) { + node.remove(); + node.parentElement = this; + this.children.unshift(node); + } + } + + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + const index = siblings.indexOf(this); + if (index >= 0) siblings.splice(index, 1); + this.parentElement = null; + } + + removeAttribute(name) { + if (name.startsWith("data-")) delete this.dataset[dataKey(name)]; + else this.attributes.delete(name); + } + + hasAttribute(name) { + if (name.startsWith("data-")) return dataKey(name) in this.dataset; + return this.attributes.has(name); + } + + addEventListener() {} + removeEventListener() {} + + matches(selector) { + if (selector === "img" || selector === "video") { + return this.tagName === selector.toUpperCase(); + } + const match = selector.match( + /^\.([^[]+)\[data-bc-role=["']([^"']+)["']\]$/, + ); + return Boolean( + match && + this.className.split(/\s+/).includes(match[1]) && + this.dataset.bcRole === match[2], + ); + } + + querySelectorAll(selector) { + const parts = selector.trim().split(/\s+/); + if (parts.length > 1) { + const rest = parts.slice(1).join(" "); + return this.querySelectorAll(parts[0]).flatMap((node) => + node.querySelectorAll(rest), + ); } + const matches = []; + for (const child of this.children) { + if (child.matches(selector)) matches.push(child); + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + } + + documentElement = new FakeElement("html"); + documentElement.style.colorScheme = "light"; + const head = new FakeElement("head"); + const body = new FakeElement("body"); + const root = new FakeElement("div"); + root.id = "root"; + documentElement.append(head, body); + body.append(root); + + const findById = (node, id) => { + if (node.id === id) return node; + for (const child of node.children) { + const match = findById(child, id); + if (match) return match; + } + return null; + }; + + const images = []; + const sourceAssignments = []; + let slowFirstAliveWhenRetryStarted = false; + class FakeImage extends FakeElement { + constructor() { + super("img"); + this.complete = false; + this.naturalWidth = 0; + this.naturalHeight = 0; + this.loadDispatches = 0; + this.srcCleared = false; + this._src = ""; + images.push(this); + } + + set src(value) { + this._src = value; + const attemptNumber = images.length; + sourceAssignments.push({ + image: this, + url: value, + slot: this.parentElement, + role: this.parentElement?.dataset.bcRole, + stage: this.parentElement?.parentElement, + }); + if (attemptNumber === 4) { + const slowFirst = images[2]; + slowFirstAliveWhenRetryStarted = + slowFirst.isConnected && !slowFirst.srcCleared && slowFirst.src !== ""; + } + if (attemptNumber !== 2 && attemptNumber !== 3) return; + const finishLoad = () => { + this.complete = true; + this.naturalWidth = 3840; + this.naturalHeight = 2160; + this.loadDispatches += 1; + this.onload?.(); + }; + if (attemptNumber === 2) queueMicrotask(finishLoad); + else setTimeout(finishLoad, 45); + } + + get src() { + return this._src; + } + + removeAttribute(name) { + if (name !== "src") return super.removeAttribute(name); + this._src = ""; + this.srcCleared = true; + } + + decode() { + return Promise.resolve(); + } + } + + class FakeVideo extends FakeElement { + constructor() { + super("video"); + } + } + + let events; + let renderHeartbeat; + const acknowledgements = []; + let resolveReadyAck; + let resolveFailedAck; + const readyAck = new Promise((resolve) => { + resolveReadyAck = resolve; + }); + const context = { + AbortController, + DOMException, + URL, + crypto: { randomUUID: () => "client-image-retry-test" }, + document: { + body, + documentElement, + head, + visibilityState: "visible", + createElement(tagName) { + return tagName === "video" ? new FakeVideo() : new FakeElement(tagName); + }, + getElementById: (id) => findById(documentElement, id), + }, + fetch: async (_url, options = {}) => { + const body = JSON.parse(options.body); + acknowledgements.push(body); + if (body.kind === "render" && body.ok === true) resolveReadyAck(body); + if (body.kind === "render" && body.ok === false) resolveFailedAck?.(body); + return { ok: true }; + }, + HTMLMediaElement: { HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3 }, + HTMLVideoElement: FakeVideo, + Image: FakeImage, + location: { href: "http://127.0.0.1:45678/" }, + matchMedia: (query) => ({ + matches: query.includes("prefers-reduced-motion"), + addEventListener() {}, + }), + MutationObserver: class { observe() {} }, + navigator: { onLine: true }, + performance: { now: () => Date.now() }, EventSource: class { constructor() { events = this; } }, - queueMicrotask: (callback) => callback(), - setInterval: () => 0, + clearInterval, + clearTimeout, + queueMicrotask, + setInterval: (callback, delay) => { + if (delay === 1_000) renderHeartbeat = callback; + return 0; + }, + setTimeout, }; context.window = context; context.globalThis = context; vm.runInNewContext(source, context); - assert.equal(documentElement.dataset.bcResolvedTone, "light"); - assert.equal(documentElement.style.colorScheme, "light"); - assert.equal(body.hasAttribute("data-ds-dark-theme"), false); - - attributes.add("data-ds-dark-theme"); - documentElement.style.colorScheme = "dark"; - observerCallback(); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(documentElement.dataset.bcResolvedTone, "dark"); - assert.equal(documentElement.style.colorScheme, "dark"); - assert.equal(body.hasAttribute("data-ds-dark-theme"), true); - - attributes.delete("data-ds-dark-theme"); - documentElement.style.colorScheme = "light"; + const imageUrl = "http://127.0.0.1:45678/media/image?t=retry-source"; events.onmessage({ - data: JSON.stringify({ type: "mode", fish: false, muted: true, tone: "dark" }), + data: JSON.stringify({ + type: "apply", + generation: 611, + media: "image", + imageUrl, + }), }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(documentElement.dataset.bcResolvedTone, "light"); - assert.equal(documentElement.style.colorScheme, "light"); - assert.equal(body.hasAttribute("data-ds-dark-theme"), false); -}); -test("authenticated apply reaches SSE client and same-origin ack becomes ready", async (t) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, TOKEN); - const plugin = await createPluginServer(tokenFile); - const events = await openEvents(plugin.origin, "client-test-01"); - t.after(async () => { - events.request.destroy(); - events.response.destroy(); - await plugin.dispose(); - await fs.rm(root, { recursive: true, force: true }); - }); + const timeout = setTimeout( + () => resolveReadyAck(new Error("timed out waiting for image ready ack")), + 1_000, + ); + const ack = await readyAck; + clearTimeout(timeout); + if (ack instanceof Error) throw ack; - const payload = { - generation: 9, + assert.equal(images.length, 2); + assert.equal(sourceAssignments.length, 2); + const [first, second] = images; + const [firstAttempt, retryAttempt] = sourceAssignments; + const stage = context.document.getElementById("beauticode-bg-stage"); + assert.equal(firstAttempt.url, imageUrl); + assert.equal(firstAttempt.role, "candidate"); + assert.equal(firstAttempt.stage, stage); + assert.equal(first.loadDispatches, 0, "the first request must be a silent timeout"); + assert.equal(first.srcCleared, true); + assert.equal(first.src, ""); + assert.equal(first.parentElement, null); + assert.equal(first.isConnected, false); + + const retriedUrl = new URL(retryAttempt.url); + assert.equal(retriedUrl.searchParams.get("t"), "retry-source"); + assert.match(retriedUrl.searchParams.get("bcImageRetry"), /^client-image-retry-test-1-/); + assert.equal(retryAttempt.slot, firstAttempt.slot); + assert.equal(retryAttempt.role, "candidate"); + assert.equal(retryAttempt.stage, stage); + assert.equal(second.complete, true); + assert.equal(second.naturalWidth, 3840); + assert.equal(second.naturalHeight, 2160); + assert.equal(second.loadDispatches, 1); + + assert.deepEqual(ack, { + clientId: "client-image-retry-test", + kind: "render", + generation: 611, media: "image", - imageUrl: "http://127.0.0.1:45678/media/image?t=secret", - }; - const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify(payload), + ok: true, + visible: true, + error: null, + playback: null, }); - assert.equal(applied.status, 200); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.match(events.read(), /"generation":9/); - - const acked = await fetch(`${plugin.origin}/__beauticode/ack`, { - method: "POST", - headers: { Origin: plugin.origin, "content-type": "application/json" }, - body: JSON.stringify({ - clientId: "client-test-01", - kind: "render", - generation: 9, + assert.equal( + acknowledgements.filter((body) => body.kind === "render").length, + 1, + ); + assert.equal(stage.children.length, 1); + assert.equal(stage.children[0], retryAttempt.slot); + assert.equal(stage.children[0].dataset.bcRole, "current"); + assert.equal(second.parentElement, stage.children[0]); + assert.equal(documentElement.dataset.bcGeneration, "611"); + + let resolveSlowReadyAck; + const slowReadyAck = new Promise((resolve) => { + resolveSlowReadyAck = resolve; + }); + resolveReadyAck = resolveSlowReadyAck; + const slowImageUrl = "http://127.0.0.1:45678/media/image?t=slow-first"; + events.onmessage({ + data: JSON.stringify({ + type: "apply", + generation: 612, media: "image", - ok: true, - visible: true, + imageUrl: slowImageUrl, }), }); - assert.equal(acked.status, 200); - - const status = await fetch(`${plugin.origin}/__beauticode/status`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - assert.deepEqual(await status.json(), { - ok: true, - connectedClients: 1, - current: { ...payload, videoUrl: null, startAt: null }, - readyClients: 1, - failedClients: 0, - visibleClients: 1, - modeReadyClients: 0, - blockedClients: 0, - resolvedTone: null, - modes: { fish: false, muted: true, tone: "auto" }, - playback: null, + // Reconnecting EventSource clients may replay the current generation. The + // duplicate must join the in-flight transaction instead of aborting its + // healthy cold request and resetting the deadline. + events.onmessage({ + data: JSON.stringify({ + type: "apply", + generation: 612, + media: "image", + imageUrl: slowImageUrl, + }), }); -}); + const slowTimeout = setTimeout( + () => resolveSlowReadyAck(new Error("timed out waiting for slow first image ack")), + 1_000, + ); + const slowAck = await slowReadyAck; + clearTimeout(slowTimeout); + if (slowAck instanceof Error) throw slowAck; -test("apply payload can carry Internal atmosphere to the browser client", async (t) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, TOKEN); - const plugin = await createPluginServer(tokenFile); - const events = await openEvents(plugin.origin, "client-atmosphere-01"); - t.after(async () => { - events.request.destroy(); - events.response.destroy(); - await plugin.dispose(); - await fs.rm(root, { recursive: true, force: true }); - }); + assert.equal(images.length, 4, "the retry window must start a parallel request"); + const slowFirst = images[2]; + const silentRetry = images[3]; + assert.equal(slowFirstAliveWhenRetryStarted, true); + assert.equal(slowFirst.loadDispatches, 1); + assert.equal(slowFirst.srcCleared, false); + assert.equal(slowFirst.src, slowImageUrl); + assert.equal(slowFirst.isConnected, true); + assert.equal(silentRetry.loadDispatches, 0); + assert.equal(silentRetry.srcCleared, true); + assert.equal(silentRetry.parentElement, null); + assert.equal(slowAck.generation, 612); + assert.equal(slowAck.ok, true); + assert.equal(documentElement.dataset.bcGeneration, "612"); + const renderAckCount = acknowledgements.filter((body) => body.kind === "render").length; + renderHeartbeat(); + await new Promise((resolve) => setImmediate(resolve)); + const heartbeatAcks = acknowledgements.filter((body) => body.kind === "render"); + assert.equal(heartbeatAcks.length, renderAckCount + 1); + assert.equal(heartbeatAcks.at(-1).generation, 612); + assert.equal(heartbeatAcks.at(-1).ok, true); - const payload = { - generation: 21, - media: "image", - imageUrl: "http://127.0.0.1:45678/media/image?t=internal", - atmosphere: { preset: "internal", rain: true, overlay: true, water: true }, - }; - const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify(payload), + const preservedSlot = stage.children[0]; + const failedAckPromise = new Promise((resolve) => { + resolveFailedAck = resolve; }); - assert.equal(applied.status, 200); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.match(events.read(), /"preset":"internal"/); - - const gallery = await fetch(`${plugin.origin}/__beauticode/apply`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify({ - generation: 23, + events.onmessage({ + data: JSON.stringify({ + type: "apply", + generation: 613, media: "image", - imageUrl: "http://127.0.0.1:45678/media/image?t=gallery", - atmosphere: { preset: "gallery", rain: true, overlay: true, water: true }, + imageUrl: "http://127.0.0.1:45678/media/image?t=both-attempts-time-out", }), }); - assert.equal(gallery.status, 200); + const failedAck = await Promise.race([ + failedAckPromise, + new Promise((resolve) => + setTimeout(() => resolve(new Error("timed out waiting for image failure ack")), 1_000), + ), + ]); + if (failedAck instanceof Error) throw failedAck; + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(failedAck.generation, 613); + assert.equal(failedAck.ok, false); + assert.equal(failedAck.visible, true); + assert.match(failedAck.error, /等待图片加载超时/); + assert.equal(stage.children.length, 1); + assert.equal(stage.children[0], preservedSlot); + assert.equal(preservedSlot.dataset.bcRole, "current"); + assert.equal(preservedSlot.isConnected, true); + assert.equal(documentElement.dataset.bcGeneration, "612"); +}); + +test("browser client separates first-frame acceptance from stable playback", async () => { + const originalSource = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); + const source = originalSource + .replace("const CLIENT_APPLY_DEADLINE_MS = 8_000;", "const CLIENT_APPLY_DEADLINE_MS = 120;") + .replace( + " function updateCommittedDom(payload) {", + ` globalThis.__testSeedCommittedSlot = (slot, payload) => { + const node = stage(); + slot.dataset.bcRole = "current"; + node.append(slot); + currentSlot = slot; + committedPayload = payload; + activePayload = payload; + renderPhase = "ready"; + updateCommittedDom(payload); + }; + globalThis.__testVerifyVideo = async (video, slot, payload, mode = "stable") => { + const controller = new AbortController(); + activePayload = payload; + renderPhase = "pending"; + slot.dataset.bcRole = "candidate"; + stage().append(slot); + try { + const verify = mode === "first-frame" ? waitForPresentedFrame : waitForStablePlayback; + await verify(video, controller.signal, CLIENT_APPLY_DEADLINE_MS); + slot.dataset.bcVideoReady = "true"; + await commitCandidate(payload, slot, controller.signal); + await acknowledgeRender(payload, true, true); + } catch (error) { + disposeSlot(slot); + restoreCommittedDom(); + await acknowledgeRender( + payload, + false, + Boolean(mountedCurrentSlot()), + error instanceof Error ? error.message : String(error), + ); + } + }; + + function updateCommittedDom(payload) {`, + ); + assert.notEqual(source, originalSource, "test must shorten the client deadline and add hooks"); + + async function runScenario(frameSteps, mode = "stable", configure = null) { + const dataKey = (attribute) => + attribute + .slice(5) + .replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + let documentElement; + + class FakeElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentElement = null; + this.dataset = {}; + this.style = {}; + this.className = ""; + this.id = ""; + this.attributes = new Map(); + } + + get isConnected() { + let node = this; + while (node.parentElement) node = node.parentElement; + return node === documentElement; + } + + append(...nodes) { + for (const node of nodes) { + node.remove(); + node.parentElement = this; + this.children.push(node); + } + } + + prepend(...nodes) { + for (const node of [...nodes].reverse()) { + node.remove(); + node.parentElement = this; + this.children.unshift(node); + } + } + + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + const index = siblings.indexOf(this); + if (index >= 0) siblings.splice(index, 1); + this.parentElement = null; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + hasAttribute(name) { + return this.attributes.has(name); + } + + removeAttribute(name) { + if (name.startsWith("data-")) delete this.dataset[dataKey(name)]; + else this.attributes.delete(name); + } + + querySelectorAll(selector) { + const tagName = selector.toUpperCase(); + const matches = []; + for (const child of this.children) { + if (child.tagName === tagName) matches.push(child); + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + + addEventListener() {} + removeEventListener() {} + } + + class FakeVideo extends FakeElement { + constructor(steps) { + super("video"); + this.steps = [...steps]; + this.listeners = new Map(); + this.frameTimers = new Map(); + this.nextFrameId = 0; + this.currentTime = 0; + this.duration = 60; + this.error = null; + this.ended = false; + this.muted = true; + this.paused = false; + this.readyState = 2; + this.networkState = 1; + this.dataset.bcPlaybackBlocked = "false"; + } + + addEventListener(name, listener) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(listener); + this.listeners.set(name, listeners); + } + + removeEventListener(name, listener) { + this.listeners.get(name)?.delete(listener); + } - const rejected = await fetch(`${plugin.origin}/__beauticode/apply`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify({ - generation: 22, + emit(name) { + for (const listener of [...(this.listeners.get(name) ?? [])]) listener({ type: name }); + } + + requestVideoFrameCallback(callback) { + const id = ++this.nextFrameId; + const step = this.steps.shift(); + if (!step) return id; + const timer = setTimeout(() => { + this.frameTimers.delete(id); + this.currentTime = step.time; + callback(Date.now(), { mediaTime: step.time }); + if (step.pauseAfter) { + this.paused = true; + this.emit("pause"); + setTimeout(() => { + this.paused = false; + this.emit("playing"); + }, 2); + } + }, 1); + this.frameTimers.set(id, timer); + return id; + } + + cancelVideoFrameCallback(id) { + const timer = this.frameTimers.get(id); + if (timer) clearTimeout(timer); + this.frameTimers.delete(id); + } + + pause() { + this.paused = true; + this.emit("pause"); + } + + load() {} + } + + documentElement = new FakeElement("html"); + documentElement.style.colorScheme = "light"; + const head = new FakeElement("head"); + const body = new FakeElement("body"); + const root = new FakeElement("div"); + root.id = "root"; + documentElement.append(head, body); + body.append(root); + const findById = (node, id) => { + if (node.id === id) return node; + for (const child of node.children) { + const match = findById(child, id); + if (match) return match; + } + return null; + }; + const acknowledgements = []; + const context = { + AbortController, + DOMException, + URL, + crypto: { randomUUID: () => "client-video-stability-test" }, + document: { + body, + documentElement, + head, + visibilityState: "visible", + createElement: (tagName) => new FakeElement(tagName), + getElementById: (id) => findById(documentElement, id), + }, + fetch: async (_url, options = {}) => { + acknowledgements.push(JSON.parse(options.body)); + return { ok: true }; + }, + HTMLMediaElement: { HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3 }, + HTMLVideoElement: FakeVideo, + Image: class {}, + location: { href: "http://127.0.0.1:45678/" }, + matchMedia: (query) => ({ + matches: query.includes("prefers-reduced-motion"), + addEventListener() {}, + }), + MutationObserver: class { + observe() {} + }, + navigator: { onLine: true }, + performance: { now: () => Date.now() }, + EventSource: class {}, + clearInterval, + clearTimeout, + queueMicrotask, + setInterval: (callback, delay) => (delay === 1_000 ? 0 : setInterval(callback, 5)), + setTimeout, + }; + context.window = context; + context.globalThis = context; + vm.runInNewContext(source, context); + + const oldPayload = { + generation: 700, media: "image", - imageUrl: "http://127.0.0.1:45678/media/image?t=internal", - atmosphere: { preset: "night" }, - }), + imageUrl: "http://127.0.0.1:45678/media/image?t=old", + }; + const oldSlot = new FakeElement("div"); + oldSlot.dataset.bcMedia = "image"; + oldSlot.dataset.bcGeneration = "700"; + oldSlot.dataset.bcImageUrl = oldPayload.imageUrl; + context.__testSeedCommittedSlot(oldSlot, oldPayload); + + const payload = { + generation: 701, + media: "video", + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + videoUrl: "http://127.0.0.1:45678/media/video?t=movie", + startAt: 0, + }; + const candidate = new FakeElement("div"); + candidate.dataset.bcMedia = "video"; + candidate.dataset.bcGeneration = "701"; + candidate.dataset.bcImageUrl = payload.imageUrl; + candidate.dataset.bcVideoUrl = payload.videoUrl; + candidate.dataset.bcStartAt = "0"; + const video = new FakeVideo(frameSteps); + configure?.(video); + candidate.append(video); + await context.__testVerifyVideo(video, candidate, payload, mode); + + return { + acknowledgements, + candidate, + documentElement, + oldSlot, + stage: context.document.getElementById("beauticode-bg-stage"), + }; + } + + const frozen = await runScenario([]); + assert.equal(frozen.acknowledgements.at(-1).ok, false); + assert.equal(frozen.acknowledgements.at(-1).visible, true); + assert.match(frozen.acknowledgements.at(-1).error, /稳定窗口/); + assert.equal(frozen.stage.children[0], frozen.oldSlot); + assert.equal(frozen.candidate.isConnected, false); + assert.equal(frozen.documentElement.dataset.bcGeneration, "700"); + + const twoFrames = await runScenario([{ time: 0.1 }, { time: 0.2 }]); + assert.equal(twoFrames.acknowledgements.at(-1).ok, false); + assert.equal(twoFrames.stage.children[0], twoFrames.oldSlot); + + const stable = await runScenario([{ time: 0.07 }, { time: 0.14 }, { time: 0.21 }]); + assert.equal(stable.acknowledgements.at(-1).ok, true); + assert.equal(stable.stage.children[0], stable.candidate); + assert.equal(stable.candidate.dataset.bcRole, "current"); + assert.equal(stable.documentElement.dataset.bcGeneration, "701"); + + const firstFrame = await runScenario([{ time: 0.01 }], "first-frame"); + assert.equal(firstFrame.acknowledgements.at(-1).ok, true); + assert.equal(firstFrame.stage.children[0], firstFrame.candidate); + + const coldFirstFrame = await runScenario([{ time: 0.01 }], "first-frame", (video) => { + video.readyState = 0; + video.networkState = 2; + setTimeout(() => { + video.readyState = 2; + video.networkState = 1; + video.emit("loadeddata"); + }, 30); + }); + assert.equal(coldFirstFrame.acknowledgements.at(-1).ok, true); + assert.equal(coldFirstFrame.stage.children[0], coldFirstFrame.candidate); + + const decodeFailure = await runScenario([], "first-frame", (video) => { + video.error = { code: 3 }; }); - assert.equal(rejected.status, 400); + assert.equal(decodeFailure.acknowledgements.at(-1).ok, false); + assert.match(decodeFailure.acknowledgements.at(-1).error, /MEDIA_ERR_DECODE/); + assert.equal(decodeFailure.stage.children[0], decodeFailure.oldSlot); + + const paused = await runScenario([ + { time: 0.08 }, + { time: 0.16, pauseAfter: true }, + { time: 0.24 }, + ]); + assert.equal(paused.acknowledgements.at(-1).ok, false, "pause must reset the stable window"); + assert.equal(paused.stage.children[0], paused.oldSlot); }); -test("video apply and display modes are broadcast and acknowledged", async (t) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, TOKEN); - const plugin = await createPluginServer(tokenFile); - const events = await openEvents(plugin.origin, "client-video-01"); - t.after(async () => { - events.request.destroy(); - events.response.destroy(); - await plugin.dispose(); - await fs.rm(root, { recursive: true, force: true }); +test("same-url video fast path ignores poster churn and permits an in-place reseek", async () => { + const originalSource = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); + const source = originalSource.replace( + " function updateCommittedDom(payload) {", + ` globalThis.__testVideoSlotMatches = (slot, payload) => { + currentSlot = slot; + return slotMatchesPayload(slot, payload); + }; + + function updateCommittedDom(payload) {`, + ); + assert.notEqual(source, originalSource, "test hook must expose the real fast-path predicate"); + + class FakeVideo {} + const stage = {}; + const video = new FakeVideo(); + Object.assign(video, { + error: null, + ended: false, + paused: false, + seeking: false, + readyState: 2, }); + const slot = { + isConnected: true, + parentElement: stage, + dataset: { + bcRole: "current", + bcMedia: "video", + bcImageUrl: "http://127.0.0.1/poster.jpg", + bcVideoUrl: "http://127.0.0.1/movie.mp4", + bcStartAt: "4", + }, + querySelector: (selector) => (selector === "video" ? video : null), + }; + const documentElement = { + dataset: {}, + style: { colorScheme: "light" }, + removeAttribute() {}, + }; + const context = { + crypto: { randomUUID: () => "client-video-fast-path-test" }, + document: { + body: { hasAttribute: () => false }, + documentElement, + head: { append() {} }, + createElement: () => ({ dataset: {}, style: {} }), + getElementById: (id) => (id === "beauticode-bg-stage" ? stage : null), + }, + fetch: async () => ({ ok: true }), + HTMLMediaElement: { HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3 }, + HTMLVideoElement: FakeVideo, + Image: class {}, + matchMedia: () => ({ matches: false, addEventListener() {} }), + MutationObserver: class { + observe() {} + }, + EventSource: class {}, + queueMicrotask, + setInterval: () => 0, + }; + context.window = context; + context.globalThis = context; + vm.runInNewContext(source, context); const payload = { - generation: 12, media: "video", - imageUrl: "http://127.0.0.1:45678/media/image?t=poster", - videoUrl: "http://127.0.0.1:45678/media/video?t=movie", - startAt: 4.25, + imageUrl: slot.dataset.bcImageUrl, + videoUrl: slot.dataset.bcVideoUrl, + startAt: 4, }; - const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify(payload), - }); - assert.equal(applied.status, 200); - - const mode = await fetch(`${plugin.origin}/__beauticode/mode`, { - method: "POST", - headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, - body: JSON.stringify({ fish: true, muted: false, tone: "light" }), - }); - assert.equal(mode.status, 200); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.match(events.read(), /"media":"video"/); - assert.match(events.read(), /"tone":"light"/); - - const headers = { Origin: plugin.origin, "content-type": "application/json" }; - assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { - method: "POST", - headers, - body: JSON.stringify({ - clientId: "client-video-01", - kind: "render", - generation: 12, - media: "video", - ok: true, - visible: true, - playback: { - currentTime: 4.8, - duration: 20, - hasVideo: true, - muted: true, - paused: false, - blocked: true, - }, - }), - })).status, 200); - assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { - method: "POST", - headers, - body: JSON.stringify({ - clientId: "client-video-01", - kind: "mode", - fish: true, - muted: true, - tone: "light", - resolvedTone: "light", - themeSynced: true, - blocked: true, + assert.equal(context.__testVideoSlotMatches(slot, payload), true); + assert.equal( + context.__testVideoSlotMatches(slot, { + ...payload, + imageUrl: "http://127.0.0.1/new-poster.jpg", }), - })).status, 200); + true, + ); - const status = await fetch(`${plugin.origin}/__beauticode/status`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - const body = await status.json(); - assert.deepEqual(body.current, payload); - assert.deepEqual(body.modes, { fish: true, muted: false, tone: "light" }); - assert.equal(body.readyClients, 1); - assert.equal(body.modeReadyClients, 1); - assert.equal(body.blockedClients, 1); - assert.equal(body.resolvedTone, "light"); - assert.equal(body.playback.currentTime, 4.8); + video.paused = true; + assert.equal(context.__testVideoSlotMatches(slot, payload), false); + video.paused = false; + video.seeking = true; + assert.equal(context.__testVideoSlotMatches(slot, payload), false); + video.seeking = false; + video.readyState = 1; + assert.equal(context.__testVideoSlotMatches(slot, payload), false); + video.readyState = 2; + assert.equal(context.__testVideoSlotMatches(slot, { ...payload, startAt: 8 }), true); }); + +test("authenticated apply reaches SSE client and same-origin ack becomes ready", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + const events = await openEvents(plugin.origin, "client-test-01"); + t.after(async () => { + events.request.destroy(); + events.response.destroy(); + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const payload = { + generation: 9, + media: "image", + imageUrl: "http://127.0.0.1:45678/media/image?t=secret", + }; + const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + assert.equal(applied.status, 200); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.match(events.read(), /"generation":9/); + + const acked = await fetch(`${plugin.origin}/__beauticode/ack`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ + clientId: "client-test-01", + kind: "render", + generation: 9, + media: "image", + ok: true, + visible: true, + }), + }); + assert.equal(acked.status, 200); + + const status = await fetch(`${plugin.origin}/__beauticode/status`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + assert.deepEqual(await status.json(), { + ok: true, + connectedClients: 1, + current: { ...payload, videoUrl: null, startAt: null }, + readyClients: 1, + failedClients: 0, + lastRenderError: null, + visibleClients: 1, + modeReadyClients: 0, + blockedClients: 0, + resolvedTone: null, + modes: { fish: false, muted: true, tone: "auto" }, + playback: null, + }); +}); + +test("apply payload can carry Internal atmosphere to the browser client", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + const events = await openEvents(plugin.origin, "client-atmosphere-01"); + t.after(async () => { + events.request.destroy(); + events.response.destroy(); + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const payload = { + generation: 21, + media: "image", + imageUrl: "http://127.0.0.1:45678/media/image?t=internal", + atmosphere: { preset: "internal", rain: true, overlay: true, water: true }, + }; + const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + assert.equal(applied.status, 200); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.match(events.read(), /"preset":"internal"/); + + const gallery = await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ + generation: 23, + media: "image", + imageUrl: "http://127.0.0.1:45678/media/image?t=gallery", + atmosphere: { preset: "gallery", rain: true, overlay: true, water: true }, + }), + }); + assert.equal(gallery.status, 200); + + const rejected = await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ + generation: 22, + media: "image", + imageUrl: "http://127.0.0.1:45678/media/image?t=internal", + atmosphere: { preset: "night" }, + }), + }); + assert.equal(rejected.status, 400); +}); + +test("video apply and display modes are broadcast and acknowledged", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + const events = await openEvents(plugin.origin, "client-video-01"); + t.after(async () => { + events.request.destroy(); + events.response.destroy(); + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const payload = { + generation: 12, + media: "video", + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + videoUrl: "http://127.0.0.1:45678/media/video?t=movie", + startAt: 4.25, + }; + const applied = await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + assert.equal(applied.status, 200); + + const mode = await fetch(`${plugin.origin}/__beauticode/mode`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ fish: true, muted: false, tone: "light" }), + }); + assert.equal(mode.status, 200); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.match(events.read(), /"media":"video"/); + assert.match(events.read(), /"tone":"light"/); + + const headers = { Origin: plugin.origin, "content-type": "application/json" }; + assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { + method: "POST", + headers, + body: JSON.stringify({ + clientId: "client-video-01", + kind: "render", + generation: 12, + media: "video", + ok: true, + visible: true, + playback: { + currentTime: 4.8, + duration: 20, + hasVideo: true, + muted: true, + paused: false, + blocked: true, + }, + }), + })).status, 200); + assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { + method: "POST", + headers, + body: JSON.stringify({ + clientId: "client-video-01", + kind: "mode", + fish: true, + muted: true, + tone: "light", + resolvedTone: "light", + themeSynced: true, + blocked: true, + }), + })).status, 200); + + const status = await fetch(`${plugin.origin}/__beauticode/status`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + const body = await status.json(); + assert.deepEqual(body.current, payload); + assert.deepEqual(body.modes, { fish: true, muted: false, tone: "light" }); + assert.equal(body.readyClients, 1); + assert.equal(body.modeReadyClients, 1); + assert.equal(body.blockedClients, 1); + assert.equal(body.resolvedTone, "light"); + assert.equal(body.playback.currentTime, 4.8); +}); + +test("transient video heartbeat is pending until an explicit renderer verdict", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-plugin-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + const events = await openEvents(plugin.origin, "client-video-pending-01"); + t.after(async () => { + events.request.destroy(); + events.response.destroy(); + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const payload = { + generation: 120, + media: "video", + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + videoUrl: "http://127.0.0.1:45678/media/video?t=movie", + startAt: 0, + }; + assert.equal((await fetch(`${plugin.origin}/__beauticode/apply`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify(payload), + })).status, 200); + + const headers = { Origin: plugin.origin, "content-type": "application/json" }; + assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { + method: "POST", + headers, + body: JSON.stringify({ + clientId: "client-video-pending-01", + kind: "render", + generation: 120, + media: "video", + ok: false, + visible: true, + error: null, + }), + })).status, 200); + + let status = await (await fetch(`${plugin.origin}/__beauticode/status`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + })).json(); + assert.equal(status.readyClients, 0); + assert.equal(status.failedClients, 0); + assert.equal(status.lastRenderError, null); + + const decodeError = "视频解码器报告失败;mediaError=MEDIA_ERR_DECODE"; + assert.equal((await fetch(`${plugin.origin}/__beauticode/ack`, { + method: "POST", + headers, + body: JSON.stringify({ + clientId: "client-video-pending-01", + kind: "render", + generation: 120, + media: "video", + ok: false, + visible: false, + error: decodeError, + }), + })).status, 200); + status = await (await fetch(`${plugin.origin}/__beauticode/status`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + })).json(); + assert.equal(status.failedClients, 1); + assert.equal(status.lastRenderError, decodeError); +}); diff --git a/integrations/deepseek-harness/test/ui-host.test.mjs b/integrations/deepseek-harness/test/ui-host.test.mjs index aa0f6cd..1c4263b 100644 --- a/integrations/deepseek-harness/test/ui-host.test.mjs +++ b/integrations/deepseek-harness/test/ui-host.test.mjs @@ -1,234 +1,649 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { apply } from "../index.mjs"; -import { parseImportFilename } from "../ui-host.mjs"; -import { - writeDshControlFile, -} from "../control-client.mjs"; - -const TOKEN = "b".repeat(64); -const PNG_1X1 = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", -); - -class FakeWebServer { - routes = new Map(); - taps = []; - - register(route) { - this.routes.set(route.path, route.handler); - return () => this.routes.delete(route.path); - } - - tapIndex(tap) { - this.taps.push(tap); - return () => this.taps.splice(this.taps.indexOf(tap), 1); - } -} - -async function createPluginServer(tokenFile) { - const webServer = new FakeWebServer(); - const effects = []; - apply( - { - webServer, - effect(factory) { - effects.push(factory()); - }, - }, - { tokenFile }, - ); - const server = http.createServer(async (req, res) => { - const pathname = new URL(req.url || "/", "http://x").pathname; - const handler = webServer.routes.get(pathname); - if (!handler) return res.writeHead(404).end(); - try { - await handler(req, res); - } catch (error) { - res.writeHead(error.statusCode || 500).end(String(error.message || error)); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - return { - origin: `http://127.0.0.1:${server.address().port}`, - dispose: async () => { - for (const effect of effects.reverse()) await effect?.(); - await new Promise((resolve) => server.close(resolve)); - }, - }; -} - -test("parseImportFilename accepts images and mp4 only", () => { - assert.equal(parseImportFilename("雨夜.png").kind, "image"); - assert.equal(parseImportFilename("C:\\\\films\\\\clip.MP4").kind, "video"); - assert.equal(parseImportFilename("..\\\\evil.txt").ok, false); - assert.match(parseImportFilename("").error, /缺少文件名/); -}); - -test("plugin injects a compact sidebar console script", async (t) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-ui-")); - const tokenFile = path.join(root, "token"); - await fs.writeFile(tokenFile, TOKEN); - const plugin = await createPluginServer(tokenFile); - t.after(async () => { - await plugin.dispose(); - await fs.rm(root, { recursive: true, force: true }); - }); - - const tap = new FakeWebServer(); - apply({ webServer: tap, effect(factory) { factory(); } }, { tokenFile }); - const injected = tap.taps[0](""); - assert.match(injected, /__beauticode\/client\.js/); - assert.match(injected, /__beauticode\/console\.js/); - - const response = await fetch(`${plugin.origin}/__beauticode/console.js`); - assert.equal(response.status, 200); - const source = await response.text(); - assert.doesNotThrow(() => new Function(source)); - assert.match(source, /beauticode-console/); - assert.match(source, /button\[aria-haspopup="dialog"\]/); - assert.doesNotMatch(source, /摸鱼/); - assert.doesNotMatch(source, / { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-ui-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile, { allowManagedUpload: true }); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + assert.equal((await fetch(`${plugin.origin}/__beauticode/ui/preset`, { method: "POST" })).status, 403); + const badPreset = await fetch(`${plugin.origin}/__beauticode/ui/preset`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ id: "night" }), + }); + assert.equal(badPreset.status, 400); + + assert.equal((await fetch(`${plugin.origin}/__beauticode/ui/status`)).status, 403); + const status = await fetch(`${plugin.origin}/__beauticode/ui/status`, { + headers: { Origin: plugin.origin }, + }); + assert.equal(status.status, 200); + const body = await status.json(); + assert.equal(typeof body.ok, "boolean"); + if (body.ok) { + assert.ok(["local", "managed", "clear"].includes(body.sourceMode)); + assert.equal(typeof body.importPolicy.nativeLocalRequired, "boolean"); + } + + assert.equal( + ( + await fetch(`${plugin.origin}/__beauticode/ui/import`, { + method: "POST", + body: PNG_1X1, + }) + ).status, + 403, + ); + const badName = await fetch(`${plugin.origin}/__beauticode/ui/import`, { + method: "POST", + headers: { + Origin: plugin.origin, + "x-beauticode-filename": "notes.txt", + }, + body: "nope", + }); + assert.equal(badName.status, 400); + assert.match((await badName.json()).error, /只支持/); + + const port = Number(new URL(plugin.origin).port); + const tooLarge = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path: "/__beauticode/ui/import", + method: "POST", + headers: { + Origin: plugin.origin, + "x-beauticode-filename": "poster.png", + "x-beauticode-theme-name": encodeURIComponent("大图测试"), + "content-length": String(20 * 1024 * 1024), + }, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + resolve({ + status: res.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"), + }); + }); + }, + ); + req.on("error", reject); + req.end(); + }); + assert.equal(tooLarge.status, 413); + assert.match(tooLarge.body.error, /过大/); + + assert.equal( + (await fetch(`${plugin.origin}/__beauticode/ui/browse`, { + method: "POST", + headers: { Origin: plugin.origin }, + })).status, + 404, + ); + assert.equal( + (await fetch(`${plugin.origin}/__beauticode/ui/import-local`, { + method: "POST", + headers: { Origin: plugin.origin }, + })).status, + 404, + ); +}); + +test("Windows picker uses a foreground owner and a parent watchdog", () => { + const script = buildWindowsPickerScript("video", { parentPid: 4242, timeoutMs: 9000 }); + assert.match(script, /\$owner\.TopMost = \$true/); + assert.match(script, /\$owner\.ShowInTaskbar = \$false/); + assert.match(script, /\$owner\.Show\(\).*\$owner\.Hide\(\).*\$owner\.Show\(\)/); + assert.match(script, /\$dialog\.ShowDialog\(\$owner\)/); + assert.match(script, /\$dshPid = 4242/); + assert.match(script, /Get-Process -Id \$dshPid/); + assert.match(script, /AddMilliseconds\(9000\)/); + assert.doesNotMatch(script, /\$dialog\.ShowDialog\(\)/); +}); + +test("Windows picker encodes the script and kills its child on abort", async () => { + class FakeChild extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + killed = false; + signals = []; + + kill(signal) { + this.killed = true; + this.signals.push(signal); + return true; + } + } + const child = new FakeChild(); + const parent = new EventEmitter(); + let spawnCall; + const picker = createWindowsMediaPicker({ + platform: "win32", + parentProcess: parent, + parentPid: 4242, + timeoutMs: 1000, + spawnProcess(command, args, options) { + spawnCall = { command, args, options }; + return child; + }, + }); + const controller = new AbortController(); + const picking = picker("image", { signal: controller.signal }); + controller.abort(); + await assert.rejects(picking, (error) => error.code === "picker_request_aborted"); + assert.equal(spawnCall.command, "powershell.exe"); + assert.equal(spawnCall.options.windowsHide, true); + assert.ok(spawnCall.args.includes("-STA")); + const encodedIndex = spawnCall.args.indexOf("-EncodedCommand"); + assert.ok(encodedIndex >= 0); + const script = Buffer.from(spawnCall.args[encodedIndex + 1], "base64").toString("utf16le"); + assert.match(script, /ShowDialog\(\$owner\)/); + assert.deepEqual(child.signals, ["SIGKILL"]); + assert.equal(parent.listenerCount("exit"), 0); +}); + +test("Windows policy refuses managed upload and requires the native local picker", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-local-contract-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile, { + allowManagedUpload: false, + pickMedia: async () => { + const error = new Error("PowerShell unavailable"); + error.code = "native_picker_unavailable"; + throw error; + }, + }); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const upload = await fetch(`${plugin.origin}/__beauticode/ui/import`, { + method: "POST", + headers: { + Origin: plugin.origin, + "x-beauticode-filename": "poster.png", + "x-beauticode-theme-name": encodeURIComponent("不应上传"), + }, + body: PNG_1X1, + }); + assert.equal(upload.status, 409); + assert.equal((await upload.json()).code, "local_import_required"); + + const pick = await fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "image" }), + }); + assert.equal(pick.status, 501); + assert.equal((await pick.json()).code, "native_picker_required"); +}); + +test("native picker cancellation, errors, and token expiry are explicit", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-picker-")); + const tokenFile = path.join(root, "token"); + const selectedImage = path.join(root, "selected.png"); + await fs.writeFile(tokenFile, TOKEN); + await fs.writeFile(selectedImage, PNG_1X1); + let clock = 1_000; + let mode = "cancel"; + const plugin = await createPluginServer(tokenFile, { + allowManagedUpload: true, + now: () => clock, + selectionTtlMs: 100, + pickMedia: async (kind) => { + if (mode === "cancel") return { ok: true, cancelled: true }; + if (mode === "unavailable") { + const error = new Error("PowerShell unavailable"); + error.code = "native_picker_unavailable"; + throw error; + } + if (mode === "error") throw new Error("picker failed"); + return { ok: true, kind, path: selectedImage, name: "selected.png" }; + }, + }); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + const pick = () => fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "image" }), + }); + + const cancelled = await pick(); + const cancelledBody = await cancelled.json(); + assert.equal(cancelledBody.ok, true); + assert.equal(cancelledBody.cancelled, true); + assert.equal(typeof cancelledBody.pickerMs, "number"); + + mode = "error"; + const failed = await pick(); + assert.equal(failed.status, 422); + assert.equal("code" in (await failed.json()), false); + + mode = "unavailable"; + const unavailable = await pick(); + assert.equal(unavailable.status, 501); + assert.equal((await unavailable.json()).code, "native_picker_unavailable"); + + mode = "select"; + const selection = await (await pick()).json(); + clock += 101; + const expired = await fetch(`${plugin.origin}/__beauticode/ui/import-selected`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ selectionId: selection.selectionId, themeName: "过期" }), + }); + assert.equal(expired.status, 410); +}); + +test("native picker permits only one dialog at a time", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-picker-busy-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + let releasePicker; + let markStarted; + const started = new Promise((resolve) => { markStarted = resolve; }); + const plugin = await createPluginServer(tokenFile, { + pickMedia: async () => { + markStarted(); + await new Promise((resolve) => { releasePicker = resolve; }); + return { ok: true, cancelled: true }; + }, + }); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + const request = () => fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "video" }), + }); + + const first = request(); + await started; + const second = await request(); + assert.equal(second.status, 409); + releasePicker(); + assert.equal((await first).status, 200); +}); + +test("native picker is cancelled and unlocked when its page disconnects", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-picker-abort-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + let calls = 0; + let markStarted; + const started = new Promise((resolve) => { markStarted = resolve; }); + const plugin = await createPluginServer(tokenFile, { + pickMedia: async (_kind, { signal }) => { + calls += 1; + if (calls > 1) return { ok: true, cancelled: true }; + markStarted(); + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => { + const error = new Error("request aborted"); + error.code = "picker_request_aborted"; + reject(error); + }, { once: true }); + }); + }, + }); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const controller = new AbortController(); + const first = fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "video" }), + signal: controller.signal, + }); + await started; + controller.abort(); + await assert.rejects(first, (error) => error.name === "AbortError"); + + const deadline = Date.now() + 1_000; + let second; + do { + second = await fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "video" }), + }); + if (second.status !== 409) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } while (Date.now() < deadline); + assert.equal(second.status, 200); + assert.equal((await second.json()).cancelled, true); +}); + +test("disposing the plugin aborts and closes an active picker request", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-picker-dispose-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + let markStarted; + let aborted = false; + const started = new Promise((resolve) => { markStarted = resolve; }); + const plugin = await createPluginServer(tokenFile, { + pickMedia: async (_kind, { signal }) => { + markStarted(); + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + const error = new Error("disposed"); + error.code = "picker_request_aborted"; + reject(error); + }, { once: true }); + }); + }, + }); + const request = fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "video" }), + }); + await started; + await plugin.dispose(); + await assert.rejects(request); + assert.equal(aborted, true); + await fs.rm(root, { recursive: true, force: true }); +}); + +test("console import reuses a live tray control plane", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-ui-")); + const tokenFile = path.join(root, "token"); + const selectedImage = path.join(root, "selected.png"); + await fs.writeFile(tokenFile, TOKEN); + await fs.writeFile(selectedImage, PNG_1X1); + const received = []; + const tray = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + received.push({ + url: req.url, + method: req.method, + authorization: req.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"), + }); + if (received.at(-1).url === "/theme/apply" && received.at(-1).body.name === "失败测试") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + ok: false, + error: "renderer failed", + sourceMode: "local", + timings: { totalMs: 1400, phases: { rendererVerify: 1200, rollback: 30 } }, + })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + generation: 3, + mode: "image", + sourceMode: received.at(-1)?.body?.input?.source ?? "managed", + timings: { totalMs: 12, phases: { rendererVerify: 3 } }, + theme: { id: "theme-test", name: "测试图片", type: "image" }, + }), + ); + }); + await new Promise((resolve) => tray.listen(0, "127.0.0.1", resolve)); + const trayUrl = `http://127.0.0.1:${tray.address().port}`; + await writeDshControlFile({ + dataRoot: root, + url: trayUrl, + token: TOKEN, + pid: process.pid, + }); + const plugin = await createPluginServer(tokenFile, { + allowManagedUpload: true, + pickMedia: async (kind) => ({ + ok: true, + kind, + path: selectedImage, + name: "selected.png", + }), + }); + t.after(async () => { + await plugin.dispose(); + await new Promise((resolve) => tray.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }); + + const imported = await fetch(`${plugin.origin}/__beauticode/ui/import`, { + method: "POST", + headers: { + Origin: plugin.origin, + "x-beauticode-filename": "poster.png", + "x-beauticode-theme-name": encodeURIComponent("测试图片"), + }, + body: PNG_1X1, + }); + assert.equal(imported.status, 200); + assert.equal((await imported.json()).ok, true); + const applied = received.find((item) => item.url === "/theme/apply"); + assert.ok(applied); + assert.equal(applied.authorization, `Bearer ${TOKEN}`); + assert.match(applied.body.input.imagePath, /\.png$/); + assert.equal(applied.body.input.source, "managed"); + + const picked = await fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "image" }), + }); + assert.equal(picked.status, 200); + const selection = await picked.json(); + assert.equal(selection.name, "selected.png"); + assert.equal(selection.suggestedThemeName, "selected"); + assert.equal(typeof selection.selectionId, "string"); + assert.equal("path" in selection, false); + assert.equal(JSON.stringify(selection).includes(selectedImage), false); + + const selected = await fetch(`${plugin.origin}/__beauticode/ui/import-selected`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ + selectionId: selection.selectionId, + themeName: "本地图片", + }), + }); + assert.equal(selected.status, 200); + const selectedBody = await selected.json(); + assert.equal(selectedBody.sourceMode, "local"); + assert.equal(typeof selectedBody.importTimings.applyAndSaveMs, "number"); + assert.equal(selectedBody.importTimings.core.phases.rendererVerify, 3); + const localApplied = received.at(-1); + assert.equal(localApplied.url, "/theme/apply"); + assert.equal(localApplied.body.name, "本地图片"); + assert.equal(localApplied.body.input.source, "local"); + assert.equal(localApplied.body.input.imagePath, selectedImage); + const timingLog = await fs.readFile(path.join(root, "logs", "import-timing.jsonl"), "utf8"); + assert.match(timingLog, /"route":"native-local"/); + assert.match(timingLog, /"sourceMode":"local"/); + + const reused = await fetch(`${plugin.origin}/__beauticode/ui/import-selected`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ + selectionId: selection.selectionId, + themeName: "重复", + }), + }); + assert.equal(reused.status, 410); + + const failedPick = await fetch(`${plugin.origin}/__beauticode/ui/pick`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ kind: "image" }), + }); + const failedSelection = await failedPick.json(); + const failedImport = await fetch(`${plugin.origin}/__beauticode/ui/import-selected`, { + method: "POST", + headers: { Origin: plugin.origin, "content-type": "application/json" }, + body: JSON.stringify({ + selectionId: failedSelection.selectionId, + themeName: "失败测试", + }), + }); + assert.equal(failedImport.status, 422); + const failedBody = await failedImport.json(); + assert.equal(failedBody.sourceMode, "local"); + assert.equal(failedBody.timings.phases.rendererVerify, 1200); + const timingLines = (await fs.readFile( + path.join(root, "logs", "import-timing.jsonl"), + "utf8", + )).trim().split("\n").map((line) => JSON.parse(line)); + const failedTiming = timingLines.at(-1); + assert.equal(failedTiming.ok, false); + assert.equal(failedTiming.sourceMode, "local"); + assert.equal(failedTiming.core.phases.rendererVerify, 1200); + assert.equal(failedTiming.core.phases.rollback, 30); +}); + +test("theme names keep the existing length and illegal-character rules", () => { + assert.deepEqual(parseImportThemeName(" 雨夜 "), { ok: true, name: "雨夜" }); + assert.equal(parseImportThemeName("").ok, false); + assert.equal(parseImportThemeName("a".repeat(81)).ok, false); + assert.equal(parseImportThemeName("坏/名字").ok, false); +}); + + diff --git a/integrations/deepseek-harness/ui-host.mjs b/integrations/deepseek-harness/ui-host.mjs index 44e4933..363ad9a 100644 --- a/integrations/deepseek-harness/ui-host.mjs +++ b/integrations/deepseek-harness/ui-host.mjs @@ -1,292 +1,793 @@ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; -import crypto from "node:crypto"; -import { pipeline } from "node:stream/promises"; -import { Transform } from "node:stream"; -import { createBeauticodeActions } from "./agent.mjs"; -import { callDshControl } from "./control-client.mjs"; -import { hasLiveTray, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs"; - -const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]); -const MAX_IMAGE_BYTES = 18 * 1024 * 1024; -const MAX_VIDEO_BYTES = 800 * 1024 * 1024; - -export function parseImportFilename(raw) { - if (typeof raw !== "string" || !raw.trim()) { - return { ok: false, error: "缺少文件名。" }; - } - let decoded = raw.trim(); - try { - decoded = decodeURIComponent(decoded); - } catch { - /* keep raw */ - } - const name = path.basename(decoded.replaceAll("\\", "/")); - if (!name || name === "." || name === "..") { - return { ok: false, error: "文件名无效。" }; - } - const ext = path.extname(name).toLowerCase(); - if (ext === ".mp4") { - return { ok: true, name, ext, kind: "video", maxBytes: MAX_VIDEO_BYTES }; - } - if (IMAGE_EXTENSIONS.has(ext)) { - return { ok: true, name, ext, kind: "image", maxBytes: MAX_IMAGE_BYTES }; - } - return { ok: false, error: "只支持图片(jpg / jpeg / png / webp / avif)或 MP4 视频。" }; -} - -function limitBytes(maxBytes) { - let size = 0; - return new Transform({ - transform(chunk, _enc, callback) { - size += chunk.length; - if (size > maxBytes) { - const error = new Error("文件过大。"); - error.statusCode = 413; - callback(error); - return; - } - callback(null, chunk); - }, - }); -} - -function publicThemes(list) { - return (Array.isArray(list) ? list : []).map((theme) => ({ - id: theme.id, - name: theme.name, - type: theme.type ?? null, - ...(theme.bundled ? { bundled: true } : {}), - })); -} - -async function canReachBridge(baseUrl) { - const origin = typeof baseUrl === "string" && baseUrl ? baseUrl : "http://127.0.0.1:3080"; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 800); - try { - const response = await fetch(new URL("__beauticode/version", origin.endsWith("/") ? origin : `${origin}/`), { - signal: controller.signal, - }); - return response.ok; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} - -export function createBeauticodeUi({ dataRoot, baseUrl, getBaseUrl, sendJson, isSameOrigin, readJson }) { - const options = { - dataRoot, - get baseUrl() { - if (typeof getBaseUrl === "function") return getBaseUrl(); - return baseUrl; - }, - }; - const actions = createBeauticodeActions(options); - let restoreStarted = false; - - async function restoreOnce() { - if (await hasLiveTray(dataRoot)) { - return callDshControl(dataRoot, { - method: "POST", - path: "/reapply", - body: {}, - timeoutMs: 30_000, - }); - } - if (!(await canReachBridge(options.baseUrl))) return; - const resolved = await resolveApplyBackend(options); - if (resolved.kind === "tray") { - return callDshControl(dataRoot, { - method: "POST", - path: "/reapply", - body: {}, - timeoutMs: 30_000, - }); - } - return resolved.session.reapply(); - } - - return { - dispose() { - return stopInProcessSession(dataRoot); - }, - scheduleRestore() { - if (restoreStarted) return; - restoreStarted = true; - queueMicrotask(() => { - void restoreOnce().catch(() => { - restoreStarted = false; - }); - }); - }, - - async status(req, res) { - if (req.method !== "GET") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - try { - const status = await actions.status(); - const listed = await actions.listThemes(); - const background = status.background ?? null; - sendJson(res, 200, { - ok: true, - media: background?.type ?? null, - muted: status.muted !== false, - atmosphere: background?.effects?.preset ?? null, - themes: publicThemes(listed.themes), - message: status.message, - }); - } catch (error) { - sendJson(res, 200, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - - async importFile(req, res) { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - const parsed = parseImportFilename(req.headers["x-beauticode-filename"]); - if (!parsed.ok) { - req.resume(); - sendJson(res, 400, { ok: false, error: parsed.error }); - return; - } - const length = Number(req.headers["content-length"]); - if (Number.isFinite(length) && length > parsed.maxBytes) { - req.resume(); - sendJson(res, 413, { ok: false, error: "文件过大。" }); - return; - } - const tmpDir = path.join(dataRoot, "tmp"); - await fsp.mkdir(tmpDir, { recursive: true }); - const tmpPath = path.join( - tmpDir, - `${Date.now()}-${crypto.randomBytes(8).toString("hex")}${parsed.ext}`, - ); - try { - await pipeline(req, limitBytes(parsed.maxBytes), fs.createWriteStream(tmpPath)); - const result = - parsed.kind === "video" - ? await actions.applyVideo({ path: tmpPath }) - : await actions.applyImage(tmpPath); - sendJson(res, 200, result); - } catch (error) { - const status = error?.statusCode === 413 ? 413 : 422; - sendJson(res, status, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } finally { - await fsp.rm(tmpPath, { force: true }).catch(() => {}); - } - }, - - async clear(req, res) { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - try { - sendJson(res, 200, await actions.clear()); - } catch (error) { - sendJson(res, 422, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - - async mode(req, res) { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - const body = await readJson(req); - if (typeof body.muted !== "boolean" || Object.keys(body).some((key) => key !== "muted")) { - sendJson(res, 400, { ok: false, error: "只接受 muted 开关。" }); - return; - } - try { - sendJson(res, 200, await actions.setMuted(body.muted)); - } catch (error) { - sendJson(res, 422, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - - async useTheme(req, res) { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - const body = await readJson(req); - if (typeof body.id !== "string" || !body.id.trim()) { - sendJson(res, 400, { ok: false, error: "必须提供主题。" }); - return; - } - try { - sendJson(res, 200, await actions.useTheme(body.id.trim())); - } catch (error) { - sendJson(res, 422, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - - async applyPreset(req, res) { - if (req.method !== "POST") { - res.writeHead(405).end(); - return; - } - if (!isSameOrigin(req)) { - res.writeHead(403).end(); - return; - } - const body = await readJson(req); - if (body.id !== "internal" && body.id !== "infernal") { - sendJson(res, 400, { ok: false, error: "未知的内置主题。" }); - return; - } - try { - sendJson(res, 200, await actions.applyPreset(body.id)); - } catch (error) { - sendJson(res, 422, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - }; -} +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { pipeline } from "node:stream/promises"; +import { Transform } from "node:stream"; +import { createBeauticodeActions } from "./agent.mjs"; +import { callDshControl } from "./control-client.mjs"; +import { createGalleryHandlers, resolveConfiguredSkinCenterUrl } from "./gallery-host.mjs"; +import { hasLiveTray, resolveApplyBackend, stopInProcessSession } from "./host-apply.mjs"; + +const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]); +const MAX_IMAGE_BYTES = 18 * 1024 * 1024; +const MAX_VIDEO_BYTES = 800 * 1024 * 1024; +const SELECTION_TTL_MS = 5 * 60 * 1000; +const MAX_PENDING_SELECTIONS = 16; +const PICKER_TIMEOUT_MS = 5 * 60 * 1000; +const NATIVE_PICKER_UNAVAILABLE = "native_picker_unavailable"; +const NATIVE_PICKER_REQUIRED = "native_picker_required"; +const PICKER_REQUEST_ABORTED = "picker_request_aborted"; +const IMPORT_TIMING_LOG = "import-timing.jsonl"; + +function elapsedMs(startedAt) { + return Math.round((performance.now() - startedAt) * 10) / 10; +} + +async function appendImportTiming(dataRoot, entry) { + try { + const logsDir = path.join(dataRoot, "logs"); + await fsp.mkdir(logsDir, { recursive: true }); + await fsp.appendFile( + path.join(logsDir, IMPORT_TIMING_LOG), + `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, + "utf8", + ); + } catch { + /* Diagnostics must never alter import success/failure semantics. */ + } +} + +export function parseImportFilename(raw) { + if (typeof raw !== "string" || !raw.trim()) { + return { ok: false, error: "缺少文件名。" }; + } + let decoded = raw.trim(); + try { + decoded = decodeURIComponent(decoded); + } catch { + /* keep raw */ + } + const name = path.basename(decoded.replaceAll("\\", "/")); + if (!name || name === "." || name === "..") { + return { ok: false, error: "文件名无效。" }; + } + const ext = path.extname(name).toLowerCase(); + if (ext === ".mp4") { + return { ok: true, name, ext, kind: "video", maxBytes: MAX_VIDEO_BYTES }; + } + if (IMAGE_EXTENSIONS.has(ext)) { + return { ok: true, name, ext, kind: "image", maxBytes: MAX_IMAGE_BYTES }; + } + return { ok: false, error: "只支持图片(jpg / jpeg / png / webp / avif)或 MP4 视频。" }; +} + +export function parseImportThemeName(raw) { + if (Array.isArray(raw)) raw = raw[0]; + if (typeof raw !== "string" || !raw.trim()) { + return { ok: false, error: "请先给该主题取名。" }; + } + let decoded = raw.trim(); + try { + decoded = decodeURIComponent(decoded); + } catch { + return { ok: false, error: "主题名编码无效。" }; + } + const name = decoded.trim(); + if (!name) return { ok: false, error: "主题名不能为空。" }; + if (name.length > 80) { + return { ok: false, error: "主题名不能超过 80 个字符。" }; + } + if (/[<>:"/\\|?*]/.test(name) || /[\u0000-\u001f]/.test(name)) { + return { ok: false, error: "主题名包含非法字符。" }; + } + return { ok: true, name }; +} + +function pickerUnavailable(message) { + const error = new Error(message); + error.code = NATIVE_PICKER_UNAVAILABLE; + return error; +} + +function isPickerDependencyError(message) { + return /powershell|system\.windows\.forms|assembly|无法加载|找不到|not recognized/i.test( + String(message || ""), + ); +} + +function suggestedThemeName(fileName, fallback) { + const ext = path.extname(fileName); + const stem = (ext ? fileName.slice(0, -ext.length) : fileName) + .replace(/[<>:"/\\|?*]/g, " ") + .replace(/[\u0000-\u001f]/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80); + return stem || fallback; +} + +export function buildWindowsPickerScript( + kind, + { parentPid = process.pid, timeoutMs = PICKER_TIMEOUT_MS } = {}, +) { + const filter = + kind === "video" + ? "MP4 Video (*.mp4)|*.mp4" + : "Image Files (*.jpg;*.jpeg;*.png;*.webp;*.avif)|*.jpg;*.jpeg;*.png;*.webp;*.avif"; + const safeParentPid = Number.isSafeInteger(parentPid) && parentPid > 0 + ? parentPid + : process.pid; + const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.ceil(timeoutMs) + : PICKER_TIMEOUT_MS; + return [ + "$ErrorActionPreference = 'Stop'", + "$OutputEncoding = New-Object System.Text.UTF8Encoding($false)", + "[Console]::OutputEncoding = $OutputEncoding", + "Add-Type -AssemblyName System.Windows.Forms", + "Add-Type -AssemblyName System.Drawing", + "[System.Windows.Forms.Application]::EnableVisualStyles()", + "$owner = New-Object System.Windows.Forms.Form", + "$owner.Text = 'beautiCode 文件选择器'", + "$owner.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedToolWindow", + "$owner.ShowInTaskbar = $false", + "$owner.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen", + "$owner.Size = [System.Drawing.Size]::new(1, 1)", + "$owner.Opacity = 0.01", + "$owner.TopMost = $true", + "$dialog = New-Object System.Windows.Forms.OpenFileDialog", + `$dialog.Filter = '${filter}'`, + "$dialog.Multiselect = $false", + "$dialog.CheckFileExists = $true", + "$dialog.RestoreDirectory = $true", + "$dialog.Title = '选择 beautiCode 背景文件'", + `$dshPid = ${safeParentPid}`, + `$deadlineUtc = [DateTime]::UtcNow.AddMilliseconds(${safeTimeoutMs})`, + "$watchdog = New-Object System.Windows.Forms.Timer", + "$watchdog.Interval = 500", + "$watchdog.Add_Tick({ if ([DateTime]::UtcNow -ge $deadlineUtc -or -not (Get-Process -Id $dshPid -ErrorAction SilentlyContinue)) { $watchdog.Stop(); [Environment]::Exit(0) } })", + "try { [void]$owner.Show(); [void]$owner.Hide(); [void]$owner.Show(); [void]$owner.Activate(); [void]$owner.BringToFront(); [System.Windows.Forms.Application]::DoEvents(); if (-not $owner.IsHandleCreated) { throw 'Picker owner window was not created.' }; $watchdog.Start(); $result = $dialog.ShowDialog($owner); if ($result -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::WriteLine($dialog.FileName) } } finally { $watchdog.Stop(); $watchdog.Dispose(); $dialog.Dispose(); $owner.Close(); $owner.Dispose() }", + ].join("; "); +} + +export function createWindowsMediaPicker({ + platform = process.platform, + spawnProcess = spawn, + parentProcess = process, + parentPid = process.pid, + timeoutMs = PICKER_TIMEOUT_MS, +} = {}) { + return function pickWindowsMedia(kind, { signal } = {}) { + if (platform !== "win32") { + throw pickerUnavailable("当前系统不支持 Windows 原生文件选择器。"); + } + const script = buildWindowsPickerScript(kind, { parentPid, timeoutMs }); + const encodedScript = Buffer.from(script, "utf16le").toString("base64"); + return new Promise((resolve, reject) => { + const child = spawnProcess( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-STA", + "-EncodedCommand", + encodedScript, + ], + { + // Hide PowerShell's console while leaving the owned WinForms dialog visible. + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + let settled = false; + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + parentProcess.removeListener("exit", onParentExit); + }; + const settle = (callback, value, { terminate = false } = {}) => { + if (settled) return; + settled = true; + cleanup(); + if (terminate && !child.killed) child.kill("SIGKILL"); + callback(value); + }; + const onAbort = () => { + const error = new Error("文件选择请求已取消。"); + error.code = PICKER_REQUEST_ABORTED; + settle(reject, error, { terminate: true }); + }; + const timer = setTimeout(() => { + settle(reject, new Error("文件选择器超时。"), { terminate: true }); + }, timeoutMs); + const onParentExit = () => { + if (!child.killed) child.kill("SIGKILL"); + }; + parentProcess.once("exit", onParentExit); + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", (error) => { + settle( + reject, + error?.code === "ENOENT" + ? pickerUnavailable("找不到 Windows PowerShell,无法打开原生文件选择器。") + : error, + ); + }); + child.once("close", (code) => { + if (settled) return; + if (code !== 0) { + const message = stderr.trim() || `文件选择器退出异常(${code})。`; + settle( + reject, + isPickerDependencyError(message) ? pickerUnavailable(message) : new Error(message), + ); + return; + } + const selected = stdout.trim(); + if (!selected) { + settle(resolve, { ok: true, cancelled: true }); + return; + } + const name = path.basename(selected.replaceAll("\\", "/")); + const parsed = parseImportFilename(name); + if (!parsed.ok || parsed.kind !== kind) { + settle(reject, new Error(parsed.ok ? "选择的文件类型不匹配。" : parsed.error)); + return; + } + settle(resolve, { ok: true, kind, path: path.resolve(selected), name }); + }); + }); + }; +} + +const pickWindowsMedia = createWindowsMediaPicker(); + +function limitBytes(maxBytes) { + let size = 0; + return new Transform({ + transform(chunk, _enc, callback) { + size += chunk.length; + if (size > maxBytes) { + const error = new Error("文件过大。"); + error.statusCode = 413; + callback(error); + return; + } + callback(null, chunk); + }, + }); +} + +function publicThemes(list) { + return (Array.isArray(list) ? list : []).map((theme) => ({ + id: theme.id, + name: theme.name, + type: theme.type ?? null, + ...(theme.bundled ? { bundled: true } : {}), + sourceMode: theme.sourceMode === "local" ? "local" : "managed", + })); +} + +async function canReachBridge(baseUrl) { + const origin = typeof baseUrl === "string" && baseUrl ? baseUrl : "http://127.0.0.1:3080"; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 800); + try { + const response = await fetch(new URL("__beauticode/version", origin.endsWith("/") ? origin : `${origin}/`), { + signal: controller.signal, + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +export function createBeauticodeUi({ + dataRoot, + baseUrl, + getBaseUrl, + sendJson, + isSameOrigin, + readJson, + pickMedia: injectedPicker, + allowManagedUpload: injectedAllowManagedUpload, + now: injectedNow, + selectionTtlMs = SELECTION_TTL_MS, +}) { + const options = { + dataRoot, + get baseUrl() { + if (typeof getBaseUrl === "function") return getBaseUrl(); + return baseUrl; + }, + }; + const actions = createBeauticodeActions(options); + const gallery = createGalleryHandlers({ dataRoot, actions }); + const nativePicker = injectedPicker ?? pickWindowsMedia; + const allowManagedUpload = + typeof injectedAllowManagedUpload === "boolean" + ? injectedAllowManagedUpload + : process.platform !== "win32"; + const now = typeof injectedNow === "function" ? injectedNow : Date.now; + const pendingSelections = new Map(); + let restoreStarted = false; + let pickerBusy = false; + let activePickerAbort = null; + let activePickerResponse = null; + let disposed = false; + + function pruneSelections() { + const timestamp = now(); + for (const [id, selection] of pendingSelections) { + if (selection.expiresAt <= timestamp) pendingSelections.delete(id); + } + while (pendingSelections.size >= MAX_PENDING_SELECTIONS) { + const oldest = pendingSelections.keys().next().value; + if (!oldest) break; + pendingSelections.delete(oldest); + } + } + + function rememberSelection(picked, pickerMs) { + pruneSelections(); + const selectionId = crypto.randomUUID(); + pendingSelections.set(selectionId, { + kind: picked.kind, + path: picked.path, + name: picked.name, + pickerMs, + expiresAt: now() + selectionTtlMs, + }); + return { + ok: true, + cancelled: false, + selectionId, + name: picked.name, + suggestedThemeName: suggestedThemeName( + picked.name, + picked.kind === "video" ? "视频" : "图片", + ), + }; + } + + async function restoreOnce() { + if (await hasLiveTray(dataRoot)) { + return callDshControl(dataRoot, { + method: "POST", + path: "/reapply", + body: {}, + timeoutMs: 30_000, + }); + } + if (!(await canReachBridge(options.baseUrl))) return; + const resolved = await resolveApplyBackend(options); + if (resolved.kind === "tray") { + return callDshControl(dataRoot, { + method: "POST", + path: "/reapply", + body: {}, + timeoutMs: 30_000, + }); + } + return resolved.session.reapply(); + } + + return { + dispose() { + disposed = true; + activePickerAbort?.abort(); + activePickerResponse?.destroy(); + activePickerAbort = null; + activePickerResponse = null; + pickerBusy = false; + pendingSelections.clear(); + return stopInProcessSession(dataRoot); + }, + scheduleRestore() { + if (restoreStarted) return; + restoreStarted = true; + queueMicrotask(() => { + void restoreOnce().catch(() => { + restoreStarted = false; + }); + }); + }, + + async status(req, res) { + if (req.method !== "GET") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + try { + const status = await actions.status(); + const listed = await actions.listThemes(); + const background = status.background ?? null; + const center = await resolveConfiguredSkinCenterUrl(); + sendJson(res, 200, { + ok: true, + media: background?.type ?? null, + muted: status.muted !== false, + atmosphere: background?.effects?.preset ?? null, + themeId: status.themeId || null, + sourceMode: status.sourceMode ?? "clear", + themes: publicThemes(listed.themes), + message: status.message, + importPolicy: { + nativeLocalRequired: !allowManagedUpload, + managedUploadAllowed: allowManagedUpload, + }, + skinCenter: { url: center, enabled: Boolean(center) }, + }); + } catch (error) { + sendJson(res, 200, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async importFile(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + if (!allowManagedUpload) { + req.resume(); + sendJson(res, 409, { + ok: false, + code: "local_import_required", + error: "Windows 本地导入必须使用原生文件选择器;不会自动上传或复制媒体文件。", + }); + return; + } + const importStartedAt = performance.now(); + const parsed = parseImportFilename(req.headers["x-beauticode-filename"]); + if (!parsed.ok) { + req.resume(); + sendJson(res, 400, { ok: false, error: parsed.error }); + return; + } + const parsedTheme = parseImportThemeName( + req.headers["x-beauticode-theme-name"], + ); + if (!parsedTheme.ok) { + req.resume(); + sendJson(res, 400, { ok: false, error: parsedTheme.error }); + return; + } + const length = Number(req.headers["content-length"]); + if (Number.isFinite(length) && length > parsed.maxBytes) { + req.resume(); + sendJson(res, 413, { ok: false, error: "文件过大。" }); + return; + } + const tmpDir = path.join(dataRoot, "tmp"); + await fsp.mkdir(tmpDir, { recursive: true }); + const tmpPath = path.join( + tmpDir, + `${Date.now()}-${crypto.randomBytes(8).toString("hex")}${parsed.ext}`, + ); + try { + await pipeline(req, limitBytes(parsed.maxBytes), fs.createWriteStream(tmpPath)); + const themeName = parsedTheme.name; + const result = + parsed.kind === "video" + ? await actions.applyVideo({ path: tmpPath, themeName, source: "managed" }) + : await actions.applyImage(tmpPath, undefined, { themeName, source: "managed" }); + await appendImportTiming(dataRoot, { + route: "managed-upload", + kind: parsed.kind, + ok: true, + sourceMode: result.sourceMode ?? "managed", + uploadAndApplyMs: elapsedMs(importStartedAt), + core: result.timings ?? null, + }); + sendJson(res, 200, result); + } catch (error) { + await appendImportTiming(dataRoot, { + route: "managed-upload", + kind: parsed.kind, + ok: false, + sourceMode: error?.sourceMode ?? "managed", + uploadAndApplyMs: elapsedMs(importStartedAt), + core: error?.timings ?? null, + error: error instanceof Error ? error.message : String(error), + }); + const status = error?.statusCode === 413 ? 413 : 422; + sendJson(res, status, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + await fsp.rm(tmpPath, { force: true }).catch(() => {}); + } + }, + + async pickMedia(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + if (body.kind !== "image" && body.kind !== "video") { + sendJson(res, 400, { ok: false, error: "kind 必须是 image 或 video。" }); + return; + } + if (pickerBusy) { + sendJson(res, 409, { ok: false, error: "文件选择器已经打开。" }); + return; + } + if (disposed) { + sendJson(res, 503, { ok: false, error: "beautiCode UI 已停止。" }); + return; + } + pickerBusy = true; + const pickerStartedAt = performance.now(); + const pickerAbort = new AbortController(); + activePickerAbort = pickerAbort; + activePickerResponse = res; + const onResponseClose = () => { + if (!res.writableEnded) pickerAbort.abort(); + }; + res.once("close", onResponseClose); + try { + const picked = await nativePicker(body.kind, { signal: pickerAbort.signal }); + if (pickerAbort.signal.aborted) return; + sendJson( + res, + 200, + picked?.cancelled + ? { ok: true, cancelled: true, pickerMs: elapsedMs(pickerStartedAt) } + : rememberSelection(picked, elapsedMs(pickerStartedAt)), + ); + } catch (error) { + if (pickerAbort.signal.aborted || error?.code === PICKER_REQUEST_ABORTED) return; + const unavailable = error?.code === NATIVE_PICKER_UNAVAILABLE; + const code = unavailable + ? allowManagedUpload + ? NATIVE_PICKER_UNAVAILABLE + : NATIVE_PICKER_REQUIRED + : undefined; + sendJson(res, unavailable ? 501 : 422, { + ok: false, + ...(code ? { code } : {}), + error: error instanceof Error ? error.message : String(error), + }); + } finally { + res.off("close", onResponseClose); + if (activePickerAbort === pickerAbort) activePickerAbort = null; + if (activePickerResponse === res) activePickerResponse = null; + pickerBusy = false; + } + }, + + async importSelected(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + const parsedTheme = parseImportThemeName(body.themeName); + const selectionId = + typeof body.selectionId === "string" ? body.selectionId.trim() : ""; + if (!selectionId || !parsedTheme.ok) { + sendJson(res, 400, { + ok: false, + error: !parsedTheme.ok ? parsedTheme.error : "选择令牌无效。", + }); + return; + } + pruneSelections(); + const selected = pendingSelections.get(selectionId); + if (!selected) { + sendJson(res, 410, { ok: false, error: "文件选择已失效,请重新选择。" }); + return; + } + pendingSelections.delete(selectionId); + const importStartedAt = performance.now(); + try { + const result = + selected.kind === "video" + ? await actions.applyVideo({ + path: selected.path, + themeName: parsedTheme.name, + source: "local", + }) + : await actions.applyImage(selected.path, undefined, { + themeName: parsedTheme.name, + source: "local", + }); + if (result.sourceMode !== "local") { + throw new Error("本地导入合同失败:后端没有保留 local 来源,操作已拒绝。"); + } + const importTimings = { + pickerMs: selected.pickerMs ?? null, + applyAndSaveMs: elapsedMs(importStartedAt), + core: result.timings ?? null, + }; + await appendImportTiming(dataRoot, { + route: "native-local", + kind: selected.kind, + ok: true, + sourceMode: "local", + ...importTimings, + }); + result.importTimings = importTimings; + sendJson(res, 200, result); + } catch (error) { + await appendImportTiming(dataRoot, { + route: "native-local", + kind: selected.kind, + ok: false, + sourceMode: error?.sourceMode ?? "local", + pickerMs: selected.pickerMs ?? null, + applyAndSaveMs: elapsedMs(importStartedAt), + core: error?.timings ?? null, + error: error instanceof Error ? error.message : String(error), + }); + sendJson(res, 422, { + ok: false, + sourceMode: error?.sourceMode ?? "local", + timings: error?.timings ?? null, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async clear(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + try { + sendJson(res, 200, await actions.clear()); + } catch (error) { + sendJson(res, 422, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async mode(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + if (typeof body.muted !== "boolean" || Object.keys(body).some((key) => key !== "muted")) { + sendJson(res, 400, { ok: false, error: "只接受 muted 开关。" }); + return; + } + try { + sendJson(res, 200, await actions.setMuted(body.muted)); + } catch (error) { + sendJson(res, 422, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async useTheme(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + if (typeof body.id !== "string" || !body.id.trim()) { + sendJson(res, 400, { ok: false, error: "必须提供主题。" }); + return; + } + try { + sendJson(res, 200, await actions.useTheme(body.id.trim())); + } catch (error) { + sendJson(res, 422, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async deleteTheme(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + if (typeof body.id !== "string" || !body.id.trim()) { + sendJson(res, 400, { ok: false, error: "必须提供主题。" }); + return; + } + try { + sendJson(res, 200, await actions.deleteTheme(body.id.trim())); + } catch (error) { + sendJson(res, 422, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + async galleryConfig(req, res) { + return gallery.config(req, res, sendJson, isSameOrigin); + }, + + async galleryCatalog(req, res) { + return gallery.catalog(req, res, sendJson, isSameOrigin); + }, + + async galleryInstall(req, res) { + return gallery.install(req, res, sendJson, isSameOrigin, readJson); + }, + + async applyPreset(req, res) { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (!isSameOrigin(req)) { + res.writeHead(403).end(); + return; + } + const body = await readJson(req); + if (body.id !== "internal" && body.id !== "infernal") { + sendJson(res, 400, { ok: false, error: "未知的内置主题。" }); + return; + } + try { + sendJson(res, 200, await actions.applyPreset(body.id)); + } catch (error) { + sendJson(res, 422, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + }; +} diff --git a/packages/adapter-codex/src/session.ts b/packages/adapter-codex/src/session.ts index e027f03..8cae49b 100644 --- a/packages/adapter-codex/src/session.ts +++ b/packages/adapter-codex/src/session.ts @@ -1,936 +1,944 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { - ApplyTransaction, - BackgroundStore, - MediaServerController, - defaultDataRoot, - buildHostApplyPayload, - resolveSessionBundledThemes, - type ApplyInput, - type ApplyResult, - type BackgroundTone, - type HostSession, - type HostSessionStatus, - type SavedThemeInfo, -} from "@beauticode/core"; -import { CodexHostApplier } from "./host-applier.js"; -import { CdpIdentityMismatchError, CdpError } from "./cdp.js"; -import { probeCdp } from "./discovery.js"; -import { findBestCdpPort } from "./host-discover.js"; -import { acquireInjectorLock } from "./injector-lock.js"; -import { loadRendererSource } from "./payload.js"; -import { CODEX_HOST_DESCRIPTOR } from "./host-descriptor.js"; - -export interface BeautiSessionOptions { - /** Fixed CDP port. If omitted, discover() is used on start. */ - port?: number; - dataRoot?: string; - verifyDeadlineMs?: number; - requireAppProtocol?: boolean; - urlPrefix?: string; - pollMs?: number; - autoDiscover?: boolean; - /** - * When true (default for tray), start() returns after store+lock init and - * connects CDP in the background. Apply/reapply still await a live host. - * Set false for one-shot CLI paths that need a connected host immediately. - */ - deferHostConnect?: boolean; - onError?: (err: Error) => void; - onStatus?: (msg: string) => void; - /** Pin the shipped 画窗 theme in 已保存主题. Default true. */ - bundledGallery?: boolean; - bundledGalleryImagePath?: string; -} - -/** - * Long-lived owner for tray / watch: - * single injector lock, media server, host applier, store. - * - * Video applies use CDP file input → blob inside the renderer. The Codex path - * keeps its HTTP media controller disabled because app:// CSP blocks loopback. - */ -export class BeautiSession implements HostSession { - readonly descriptor = CODEX_HOST_DESCRIPTOR; - readonly dataRoot: string; - readonly verifyDeadlineMs: number; - readonly requireAppProtocol: boolean; - readonly urlPrefix: string | undefined; - readonly pollMs: number; - readonly autoDiscover: boolean; - readonly deferHostConnect: boolean; - - private port: number | null; - private store: BackgroundStore; - private media = new MediaServerController({ enabled: false }); - private host: CodexHostApplier | null = null; - private releaseLock: (() => Promise) | null = null; - private watchTimer: ReturnType | null = null; - private closed = false; - /** User-facing apply/reapply/theme — must not be blocked by watch polls. */ - private userBusy = false; - /** Watch tick in flight — separate so tray switches are not rejected. */ - private watchBusy = false; - private startupTask: Promise | null = null; - private watchTask: Promise | null = null; - private activeOperations = new Set>(); - private stopTask: Promise | null = null; - /** Ensures only one ensureHost chain runs at a time. */ - private hostConnectChain: Promise = Promise.resolve(); - /** Session id set last time we successfully published media URLs. */ - private lastPublishSessionKey = ""; - /** Runtime-detached video generation already installed in the live session. */ - private detachedVideoKey = ""; - /** - * Fish mode (摸鱼) desired state for this process only — not persisted. - * Re-asserted after every successful publish so watch/reapply cannot drop it. - */ - private fishMode = false; - /** - * Background video mute preference (default muted). Process-local only. - * Independent of fish mode. Re-asserted after publish / blob attach. - */ - private videoMuted = true; - /** CSS overlay preference; process-local and dark by default for compatibility. */ - private backgroundTone: BackgroundTone = "dark"; - /** - * Saved theme currently bound for continuous video-progress writes. - * Set on useSavedTheme; cleared when the user applies a different media path - * (image/video file picker, clear). Progress is written into that theme's - * theme.json only — never into a different theme. - */ - private activeThemeId: string | null = null; - /** Throttle continuous theme progress disk writes. */ - private lastProgressWriteAt = 0; - private lastProgressWriteSec = -1; - private progressWriteInFlight = false; - private onError: ((err: Error) => void) | null; - private onStatus: ((msg: string) => void) | null; - - constructor(opts: BeautiSessionOptions = {}) { - this.dataRoot = opts.dataRoot ?? defaultDataRoot(); - this.port = opts.port ?? null; - this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000; - this.requireAppProtocol = opts.requireAppProtocol ?? true; - this.urlPrefix = opts.urlPrefix; - this.pollMs = opts.pollMs ?? 1_000; - this.autoDiscover = opts.autoDiscover ?? true; - // Default deferred: tray wants the control plane up immediately. - this.deferHostConnect = opts.deferHostConnect ?? true; - this.onError = opts.onError ?? null; - this.onStatus = opts.onStatus ?? null; - this.store = new BackgroundStore({ - root: this.dataRoot, - bundledThemes: resolveSessionBundledThemes({ - enabled: opts.bundledGallery, - imagePath: opts.bundledGalleryImagePath, - searchRoots: [ - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."), - process.cwd(), - ], - }), - }); - } - - get cdpPort(): number | null { - return this.port; - } - - get isBusy(): boolean { - return this.userBusy; - } - - get isOpen(): boolean { - // Open once start() acquired the injector lock — host may still be connecting. - return !this.closed && this.releaseLock != null; - } - - get isHostReady(): boolean { - return Boolean(this.host && this.port != null && this.host.activeSessionCount > 0); - } - - get activeSessionCount(): number { - return this.host?.activeSessionCount ?? 0; - } - - get isFishMode(): boolean { - return this.fishMode; - } - - get isVideoMuted(): boolean { - return this.videoMuted; - } - - async start(): Promise<{ port: number | null }> { - this.assertOpenable(); - await this.store.init(); - - // Hold the single-owner lock early (port 0 = not yet bound to a CDP port). - // Real port is written once ensureHost resolves a live endpoint. - this.releaseLock = await acquireInjectorLock(this.dataRoot, this.port ?? 0); - - if (!this.deferHostConnect) { - await this.ensureHost({ allowDiscover: true }); - await this.republishActive().catch((err) => { - this.onError?.(err instanceof Error ? err : new Error(String(err))); - }); - this.startWatchLoop(); - return { port: this.port }; - } - - // Fast path for tray: return immediately, connect + publish in background. - this.startWatchLoop(); - const startup = this.ensureHost({ allowDiscover: true }) - .then(() => this.republishActive()) - .catch((err) => { - this.onError?.(err instanceof Error ? err : new Error(String(err))); - }); - this.startupTask = startup; - void startup.finally(() => { - if (this.startupTask === startup) this.startupTask = null; - }); - return { port: this.port }; - } - - /** - * Resolve CDP port (if needed), open host sessions, and keep host non-null. - * Serialized — concurrent apply/watch share one connect attempt. - */ - private ensureHost(opts: { allowDiscover?: boolean } = {}): Promise { - const run = async () => { - if (this.closed) throw new CdpError("Session already stopped"); - - if (this.port == null) { - if (!(opts.allowDiscover ?? true) || !this.autoDiscover) { - throw new CdpError( - "No CDP port configured. Pass --port or enable auto-discover.", - ); - } - this.onStatus?.("Discovering loopback Codex CDP…"); - const best = await findBestCdpPort({ - requirePages: true, - timeoutMs: 450, - }); - if (!best) { - throw new CdpError( - "No healthy loopback Codex CDP endpoint found. Open Codex Desktop, then use tray 应用或重新应用.", - ); - } - if (this.closed) throw new CdpError("Session already stopped"); - this.port = best.port; - this.onStatus?.( - `Using CDP :${best.port} (${best.browser ?? "unknown"}; primaryPages=${best.primaryPages})`, - ); - } - - await probeCdp(this.port, "127.0.0.1", { timeoutMs: 800 }); - if (this.closed) throw new CdpError("Session already stopped"); - if (!this.host || this.host.port !== this.port) { - this.host?.close(); - this.host = this.createHost(this.port); - this.detachedVideoKey = ""; - } - const connectCurrentHost = async () => { - if (!this.host) throw new CdpError("Host is not connected"); - if (this.host.activeSessionCount === 0) { - await this.host.connect(); - } else { - await this.host.reconcileSessions(); - if (this.host.activeSessionCount === 0) { - await this.host.connect(); - } - } - }; - - try { - await connectCurrentHost(); - } catch (err) { - if (!(err instanceof CdpIdentityMismatchError) || this.port == null) { - throw err; - } - - // A normal Codex restart keeps the loopback port but replaces the - // Chromium browser identity. Drop every stale target and bind once to - // the freshly probed browser so the triggering user action can finish. - this.onStatus?.(`Codex CDP restarted on :${this.port}; reconnecting…`); - this.host?.close(); - this.host = this.createHost(this.port); - this.detachedVideoKey = ""; - this.lastPublishSessionKey = ""; - await connectCurrentHost(); - } - if (this.closed) { - this.host.close(); - throw new CdpError("Session already stopped"); - } - }; - - this.hostConnectChain = this.hostConnectChain.then(run, run); - return this.hostConnectChain; - } - - async apply(input: ApplyInput): Promise { - return this.#trackOperation(this.applyInternal(input)); - } - - private async applyInternal(input: ApplyInput): Promise { - if (this.closed || !this.releaseLock) { - throw new CdpError("Session is not started"); - } - if (this.userBusy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.userBusy = true; - try { - await this.ensureHost({ allowDiscover: true }); - if (!this.host || this.port == null) { - throw new CdpError("Host is not connected"); - } - const { cssText } = await loadRendererSource(); - const tx = new ApplyTransaction({ - store: this.store, - media: this.media, - host: this.host, - cssText, - verifyDeadlineMs: this.verifyDeadlineMs, - offline: false, - }); - const result = await tx.run(input); - if (result.ok) { - this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; - this.detachedVideoKey = - input.type === "video" - ? `${this.lastPublishSessionKey}:${result.generation}` - : ""; - // Manual apply leaves the previous theme binding — progress must not - // keep writing into a theme the user is no longer viewing. - this.activeThemeId = null; - this.lastProgressWriteSec = -1; - // clear always exits fish; other applies re-assert if still wanted. - if (input.type === "clear") { - this.fishMode = false; - } else if (this.fishMode) { - await this.host.setFishMode(true).catch(() => null); - } - // Mute preference is independent of clear; re-assert on live video. - if (!this.videoMuted || input.type === "video") { - await this.host.setMuted(this.videoMuted).catch(() => null); - } - await this.reassertBackgroundTone(); - } - return result; - } finally { - this.userBusy = false; - } - } - - async status(): Promise { - await this.store.init(); - const manifest = await this.store.readActiveManifest(); - return { - host: this.descriptor, - port: this.port, - sessions: this.host?.activeSessionCount ?? 0, - manifest, - mediaServer: - this.media.activeImage?.url ?? this.media.activeVideo?.url ?? null, - fish: this.fishMode, - muted: this.videoMuted, - tone: this.backgroundTone, - }; - } - - /** - * Toggle fish mode (摸鱼). Attribute-only in the renderer — no media rebuild. - * Not persisted across tray/process restarts. Requires an active background. - */ - async setFishMode(enabled: boolean): Promise<{ - ok: boolean; - fish: boolean; - sessions: number; - error?: string; - }> { - if (this.closed || !this.releaseLock) { - return { - ok: false, - fish: this.fishMode, - sessions: 0, - error: "Session is not started", - }; - } - const want = Boolean(enabled); - if (want) { - await this.store.init(); - const manifest = await this.store.readActiveManifest(); - if (!manifest.background) { - this.fishMode = false; - return { - ok: false, - fish: false, - sessions: 0, - error: "No active background. Apply an image or video first.", - }; - } - } - // Remember desired state even if host is momentarily down; watch will - // re-assert after the next successful publish. - this.fishMode = want; - try { - await this.ensureHost({ allowDiscover: true }); - } catch (err) { - return { - ok: false, - fish: this.fishMode, - sessions: 0, - error: err instanceof Error ? err.message : String(err), - }; - } - if (!this.host) { - return { - ok: false, - fish: this.fishMode, - sessions: 0, - error: "Host is not connected", - }; - } - const result = await this.host.setFishMode(want); - // Host may refuse (e.g. clear media) — mirror that. - if (result.ok) this.fishMode = result.fish; - else if (!want) this.fishMode = false; - return result; - } - - /** - * Toggle background video mute. Attribute/property only — no media rebuild. - * Default muted. Not persisted across tray/process restarts. - * Independent of fish mode. If unmute is blocked by autoplay policy the - * video keeps playing muted and `blocked: true` is returned. - */ - async setMuted(muted: boolean): Promise<{ - ok: boolean; - muted: boolean; - blocked: boolean; - sessions: number; - error?: string; - }> { - if (this.closed || !this.releaseLock) { - return { - ok: false, - muted: this.videoMuted, - blocked: false, - sessions: 0, - error: "Session is not started", - }; - } - this.videoMuted = Boolean(muted); - try { - await this.ensureHost({ allowDiscover: true }); - } catch (err) { - // Preference kept for the next successful publish. - return { - ok: true, - muted: this.videoMuted, - blocked: false, - sessions: 0, - error: err instanceof Error ? err.message : String(err), - }; - } - if (!this.host) { - return { - ok: true, - muted: this.videoMuted, - blocked: false, - sessions: 0, - }; - } - const result = await this.host.setMuted(this.videoMuted); - if (result.ok) this.videoMuted = result.muted; - return result; - } - - /** Change only the injected CSS overlay; media and generation are untouched. */ - async setBackgroundTone(tone: BackgroundTone): Promise<{ - ok: boolean; - tone: BackgroundTone; - sessions: number; - error?: string; - }> { - if (this.closed || !this.releaseLock) { - return { - ok: false, - tone: this.backgroundTone, - sessions: 0, - error: "Session is not started", - }; - } - this.backgroundTone = - tone === "light" || tone === "auto" ? tone : "dark"; - try { - await this.ensureHost({ allowDiscover: true }); - } catch (err) { - // Keep the preference for the next Codex connection. - return { - ok: true, - tone: this.backgroundTone, - sessions: 0, - error: err instanceof Error ? err.message : String(err), - }; - } - if (!this.host || !this.host.setBackgroundTone) { - return { ok: true, tone: this.backgroundTone, sessions: 0 }; - } - const result = await this.host.setBackgroundTone(this.backgroundTone); - if (result.ok) this.backgroundTone = result.tone; - return result; - } - - /** - * Re-publish the currently active background into live sessions without - * bumping generation / re-importing media (tray "应用或重新应用"). - */ - async reapply(): Promise { - return this.#trackOperation(this.reapplyInternal()); - } - - private async reapplyInternal(): Promise { - if (this.closed || !this.releaseLock) { - throw new CdpError("Session is not started"); - } - if (this.userBusy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.userBusy = true; - try { - await this.ensureHost({ allowDiscover: true }); - if (!this.host || this.port == null) { - throw new CdpError("Host is not connected"); - } - const manifest = await this.store.readActiveManifest(); - await this.republishActive(); - const verify = await this.host.verify( - { - generation: manifest.generation, - media: manifest.background?.type ?? "clear", - }, - { deadlineMs: this.verifyDeadlineMs }, - ); - if (verify.status !== "pass") { - return { - ok: false, - error: `Live verify did not pass (${verify.status}): ${verify.reason}`, - rolledBack: false, - }; - } - this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; - return { - ok: true, - generation: manifest.generation, - mode: manifest.background?.type ?? "clear", - }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - rolledBack: false, - }; - } finally { - this.userBusy = false; - } - } - - /** Persist the active image/video under a user-facing name. */ - async saveCurrentTheme(name: string): Promise { - return this.#trackOperation(this.saveCurrentThemeInternal(name)); - } - - private async saveCurrentThemeInternal(name: string): Promise { - if (this.closed || !this.releaseLock) { - throw new CdpError("Session is not started"); - } - await this.store.init(); - let videoPositionSec: number | null = null; - // Capture live progress at save time when a video is playing. - if (this.host) { - try { - const pos = await this.host.getPlaybackPosition(); - if (pos.ok && pos.hasVideo && Number.isFinite(pos.currentTime)) { - videoPositionSec = pos.currentTime; - } - } catch { - /* save without position */ - } - } - const theme = await this.store.saveCurrentTheme(name, { videoPositionSec }); - // Bind continuous writes to the freshly saved theme when it is video. - if (theme.type === "video") { - this.activeThemeId = theme.id; - this.lastProgressWriteSec = -1; - } else { - this.activeThemeId = null; - } - return theme; - } - - async listSavedThemes(): Promise { - await this.store.init(); - return this.store.listSavedThemes(); - } - - async deleteSavedTheme(themeId: string): Promise { - const id = String(themeId ?? "").trim(); - const deleted = await this.store.deleteSavedTheme(id); - if (deleted && this.activeThemeId === id) { - this.activeThemeId = null; - this.lastProgressWriteSec = -1; - } - return deleted; - } - - /** - * Restore a previously saved theme into active and publish to live sessions. - * Video themes resume at the last written position (invalid → 0). - * Binds continuous progress writes to this theme id. - */ - async useSavedTheme(themeId: string): Promise { - return this.#trackOperation(this.useSavedThemeInternal(themeId)); - } - - private async useSavedThemeInternal(themeId: string): Promise { - if (this.closed || !this.releaseLock) { - throw new CdpError("Session is not started"); - } - if (this.userBusy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.userBusy = true; - try { - const saved = await this.store.loadSavedTheme(themeId); - await this.ensureHost({ allowDiscover: true }); - if (!this.host || this.port == null) { - throw new CdpError("Host is not connected"); - } - const { cssText } = await loadRendererSource(); - const tx = new ApplyTransaction({ - store: this.store, - media: this.media, - host: this.host, - cssText, - verifyDeadlineMs: this.verifyDeadlineMs, - offline: false, - }); - const result = await tx.run(saved.input); - if (!result.ok) return result; - - // Bind progress only after disk, host apply and live verify all passed. - this.activeThemeId = - saved.input.type === "video" ? saved.themeId : null; - this.detachedVideoKey = - saved.input.type === "video" - ? `${this.port}:${this.host.activeSessionCount}:${result.generation}` - : ""; - this.lastProgressWriteSec = -1; - this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; - if (this.fishMode) { - await this.host.setFishMode(true).catch(() => null); - } - if (!this.videoMuted || saved.input.type === "video") { - await this.host.setMuted(this.videoMuted).catch(() => null); - } - await this.reassertBackgroundTone(); - return result; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - rolledBack: false, - }; - } finally { - this.userBusy = false; - } - } - - async stop(): Promise { - if (this.stopTask) return this.stopTask; - const task = this.stopExclusive(); - this.stopTask = task; - return task; - } - - private async stopExclusive(): Promise { - this.closed = true; - if (this.watchTimer) { - clearInterval(this.watchTimer); - this.watchTimer = null; - } - // Best-effort restore host UI before tearing down CDP (tray quit path). - if (this.fishMode && this.host) { - try { - await this.host.setFishMode(false); - } catch { - /* ignore */ - } - this.fishMode = false; - } - this.host?.close(); - const pending = [ - this.startupTask, - this.watchTask, - this.hostConnectChain, - ...this.activeOperations, - ].filter((value): value is Promise => Boolean(value)); - await Promise.allSettled(pending); - this.host?.close(); - this.host = null; - await this.media.close().catch(() => {}); - if (this.releaseLock) { - await this.releaseLock().catch(() => {}); - this.releaseLock = null; - } - } - - private createHost(port: number): CodexHostApplier { - const options: ConstructorParameters[0] = { - port, - requireAppProtocol: this.requireAppProtocol, - pollMs: Math.min(this.pollMs, 400), - }; - if (this.urlPrefix !== undefined) options.urlPrefix = this.urlPrefix; - return new CodexHostApplier(options); - } - - private startWatchLoop(): void { - if (this.watchTimer) return; - this.watchTimer = setInterval(() => { - const task = this.watchTick(); - this.watchTask = task; - void task.finally(() => { - if (this.watchTask === task) this.watchTask = null; - }); - }, this.pollMs); - this.watchTimer.unref?.(); - } - - private async watchTick(): Promise { - // Skip while a user apply is running, or if a previous watch tick is open. - // Never hold userBusy here — that made tray image/video switches fail with - // "already in progress" and forced multi-retry. - if (this.closed || this.userBusy || this.watchBusy || !this.releaseLock) { - return; - } - this.watchBusy = true; - try { - // Soft connect: if Codex is not up yet, stay quiet until the next tick - // (or until the user hits 应用或重新应用 which launches the host). - try { - await this.ensureHost({ allowDiscover: true }); - } catch { - return; - } - if (!this.host || this.port == null) return; - - await this.host.reconcileSessions(); - if (this.host.activeSessionCount === 0) { - await this.host.connect(); - } - // After Codex restart session ids change. Always restage fresh media URLs - // for new sessions — reapplyLast would re-inject dead/stale loopback URLs - // and surface as "fail fetch" in the renderer. - // When the session set is stable, only heal missing/unhealthy runtimes. - // Full reapply every poll was the main image↔video alternate flash source - // (session 019fa31c / Dream Skin watch path). - const sessionKey = `${this.port}:${this.host.activeSessionCount}`; - if ( - sessionKey !== this.lastPublishSessionKey || - !this.host.lastApplied - ) { - await this.republishActive(); - this.lastPublishSessionKey = sessionKey; - } else { - await this.host.reapplyLast(); - } - // Continuous per-theme progress write (video only, bound theme only). - await this.persistBoundThemeProgress().catch(() => {}); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - this.onError?.(error); - if (err instanceof CdpIdentityMismatchError && this.port != null) { - try { - this.host?.close(); - this.host = this.createHost(this.port); - this.lastPublishSessionKey = ""; - await this.ensureHost({ allowDiscover: false }); - await this.republishActive(); - this.lastPublishSessionKey = `${this.port}:${this.host?.activeSessionCount ?? 0}`; - } catch (re) { - this.onError?.(re instanceof Error ? re : new Error(String(re))); - } - } - } finally { - this.watchBusy = false; - } - } - - #trackOperation(operation: Promise): Promise { - this.activeOperations.add(operation); - void operation.then( - () => this.activeOperations.delete(operation), - () => this.activeOperations.delete(operation), - ); - return operation; - } - - /** - * While a saved video theme is active, periodically write currentTime into - * that theme's theme.json. Fail-soft; never blocks user applies. - */ - private async persistBoundThemeProgress(): Promise { - if ( - !this.activeThemeId || - !this.host || - this.progressWriteInFlight || - this.userBusy - ) { - return; - } - const now = Date.now(); - // ~2s cadence is enough for resume and keeps disk quiet. - if (now - this.lastProgressWriteAt < 2_000) return; - - this.progressWriteInFlight = true; - try { - const pos = await this.host.getPlaybackPosition(); - if (!pos.ok || !pos.hasVideo) return; - let t = Number(pos.currentTime); - if (!Number.isFinite(t) || t < 0) return; - // Near end: store 0 so next restore starts cleanly (product: invalid → 0). - if (pos.duration > 0 && t >= pos.duration - 0.25) { - t = 0; - } - // Skip tiny moves (<0.5s) to cut writes further. - if ( - this.lastProgressWriteSec >= 0 && - Math.abs(t - this.lastProgressWriteSec) < 0.5 && - !(t === 0 && this.lastProgressWriteSec !== 0) - ) { - this.lastProgressWriteAt = now; - return; - } - const result = await this.store.updateSavedThemeVideoPosition( - this.activeThemeId, - t, - ); - if (result.ok) { - this.lastProgressWriteAt = now; - this.lastProgressWriteSec = - result.positionSec != null ? result.positionSec : t; - } else if (result.error === "Saved theme not found.") { - // Theme deleted under us — stop binding. - this.activeThemeId = null; - } - } finally { - this.progressWriteInFlight = false; - } - } - - private async republishActive(): Promise { - if (!this.host) return; - const manifest = await this.store.readActiveManifest(); - const { cssText } = await loadRendererSource(); - - if (!manifest.background) { - this.fishMode = false; - this.activeThemeId = null; - this.detachedVideoKey = ""; - await this.host.apply( - await buildHostApplyPayload(this.store, manifest, null, cssText), - ); - await this.reassertBackgroundTone(); - await this.media.commit(null); - return; - } - - const imagePath = path.join( - this.store.paths.activeDir, - manifest.background.image, - ); - // Codex CSP requires data:/blob:; loopback is secondary only. - const imageHandle = await this.media.stage(imagePath); - let videoHandle = null as Awaited>; - - if (manifest.background.type === "video" && manifest.background.video) { - const videoPath = path.join( - this.store.paths.activeDir, - manifest.background.video, - ); - videoHandle = await this.media.stage(videoPath); - // Codex Desktop primary: CDP file-input → blob: (Dream Skin path). - // Skip multi-MB dataUrl — host applier attaches the local file. - } - - let committed = false; - let runtimeVideoPath: string | null = null; - const videoKey = - manifest.background.type === "video" - ? `${this.port}:${this.host.activeSessionCount}:${manifest.generation}` - : ""; - const forceVideoRebuild = - Boolean(videoKey) && this.detachedVideoKey !== videoKey; - const previousDetachedVideoKey = this.detachedVideoKey; - if (forceVideoRebuild) this.detachedVideoKey = videoKey; - try { - const payload = await buildHostApplyPayload( - this.store, - manifest, - { image: imageHandle, video: videoHandle }, - cssText, - ); - runtimeVideoPath = payload.video?.localPath ?? null; - await this.host.apply(payload, { forceRebuild: forceVideoRebuild }); - await this.media.commit({ image: imageHandle, video: videoHandle }); - await this.store.pruneRuntimeMedia(runtimeVideoPath); - committed = true; - } catch (error) { - if ( - forceVideoRebuild && - this.detachedVideoKey === videoKey - ) { - this.detachedVideoKey = previousDetachedVideoKey; - } - throw error; - } finally { - if (!committed) { - await this.media.abort(videoHandle).catch(() => {}); - await this.media.abort(imageHandle).catch(() => {}); - } - } - // Watch / reapply must not drop fish / mute prefs after a reinject. - if (this.fishMode) { - await this.host.setFishMode(true).catch(() => null); - } - await this.host.setMuted(this.videoMuted).catch(() => null); - await this.reassertBackgroundTone(); - } - - private async reassertBackgroundTone(): Promise { - if (!this.host?.setBackgroundTone) return; - await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); - } - - private assertOpenable(): void { - if (this.closed) throw new CdpError("Session already stopped"); - if (this.releaseLock) throw new CdpError("Session already started"); - } -} +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ApplyTransaction, + BackgroundStore, + MediaServerController, + defaultDataRoot, + buildHostApplyPayload, + resolveSessionBundledThemes, + isLocalBackgroundSource, + resolveBackgroundImagePath, + type ApplyInput, + type ApplyResult, + type BackgroundTone, + type HostSession, + type HostSessionStatus, + type SavedThemeInfo, +} from "@beauticode/core"; +import { CodexHostApplier } from "./host-applier.js"; +import { CdpIdentityMismatchError, CdpError } from "./cdp.js"; +import { probeCdp } from "./discovery.js"; +import { findBestCdpPort } from "./host-discover.js"; +import { acquireInjectorLock } from "./injector-lock.js"; +import { loadRendererSource } from "./payload.js"; +import { CODEX_HOST_DESCRIPTOR } from "./host-descriptor.js"; + +export interface BeautiSessionOptions { + /** Fixed CDP port. If omitted, discover() is used on start. */ + port?: number; + dataRoot?: string; + verifyDeadlineMs?: number; + requireAppProtocol?: boolean; + urlPrefix?: string; + pollMs?: number; + autoDiscover?: boolean; + /** + * When true (default for tray), start() returns after store+lock init and + * connects CDP in the background. Apply/reapply still await a live host. + * Set false for one-shot CLI paths that need a connected host immediately. + */ + deferHostConnect?: boolean; + onError?: (err: Error) => void; + onStatus?: (msg: string) => void; + /** Pin the shipped 画窗 theme in 已保存主题. Default true. */ + bundledGallery?: boolean; + bundledGalleryImagePath?: string; +} + +/** + * Long-lived owner for tray / watch: + * single injector lock, media server, host applier, store. + * + * Video applies use CDP file input → blob inside the renderer. The Codex path + * keeps its HTTP media controller disabled because app:// CSP blocks loopback. + */ +export class BeautiSession implements HostSession { + readonly descriptor = CODEX_HOST_DESCRIPTOR; + readonly dataRoot: string; + readonly verifyDeadlineMs: number; + readonly requireAppProtocol: boolean; + readonly urlPrefix: string | undefined; + readonly pollMs: number; + readonly autoDiscover: boolean; + readonly deferHostConnect: boolean; + + private port: number | null; + private store: BackgroundStore; + private media = new MediaServerController({ enabled: false }); + private host: CodexHostApplier | null = null; + private releaseLock: (() => Promise) | null = null; + private watchTimer: ReturnType | null = null; + private closed = false; + /** User-facing apply/reapply/theme — must not be blocked by watch polls. */ + private userBusy = false; + /** Watch tick in flight — separate so tray switches are not rejected. */ + private watchBusy = false; + private startupTask: Promise | null = null; + private watchTask: Promise | null = null; + private activeOperations = new Set>(); + private stopTask: Promise | null = null; + /** Ensures only one ensureHost chain runs at a time. */ + private hostConnectChain: Promise = Promise.resolve(); + /** Session id set last time we successfully published media URLs. */ + private lastPublishSessionKey = ""; + /** Runtime-detached video generation already installed in the live session. */ + private detachedVideoKey = ""; + /** + * Fish mode (摸鱼) desired state for this process only — not persisted. + * Re-asserted after every successful publish so watch/reapply cannot drop it. + */ + private fishMode = false; + /** + * Background video mute preference (default muted). Process-local only. + * Independent of fish mode. Re-asserted after publish / blob attach. + */ + private videoMuted = true; + /** CSS overlay preference; process-local and dark by default for compatibility. */ + private backgroundTone: BackgroundTone = "dark"; + /** + * Saved theme currently bound for continuous video-progress writes. + * Set on useSavedTheme; cleared when the user applies a different media path + * (image/video file picker, clear). Progress is written into that theme's + * theme.json only — never into a different theme. + */ + private activeThemeId: string | null = null; + /** Throttle continuous theme progress disk writes. */ + private lastProgressWriteAt = 0; + private lastProgressWriteSec = -1; + private progressWriteInFlight = false; + private onError: ((err: Error) => void) | null; + private onStatus: ((msg: string) => void) | null; + + constructor(opts: BeautiSessionOptions = {}) { + this.dataRoot = opts.dataRoot ?? defaultDataRoot(); + this.port = opts.port ?? null; + this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000; + this.requireAppProtocol = opts.requireAppProtocol ?? true; + this.urlPrefix = opts.urlPrefix; + this.pollMs = opts.pollMs ?? 1_000; + this.autoDiscover = opts.autoDiscover ?? true; + // Default deferred: tray wants the control plane up immediately. + this.deferHostConnect = opts.deferHostConnect ?? true; + this.onError = opts.onError ?? null; + this.onStatus = opts.onStatus ?? null; + this.store = new BackgroundStore({ + root: this.dataRoot, + bundledThemes: resolveSessionBundledThemes({ + enabled: opts.bundledGallery, + imagePath: opts.bundledGalleryImagePath, + searchRoots: [ + path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."), + process.cwd(), + ], + }), + }); + } + + get cdpPort(): number | null { + return this.port; + } + + get isBusy(): boolean { + return this.userBusy; + } + + get isOpen(): boolean { + // Open once start() acquired the injector lock — host may still be connecting. + return !this.closed && this.releaseLock != null; + } + + get isHostReady(): boolean { + return Boolean(this.host && this.port != null && this.host.activeSessionCount > 0); + } + + get activeSessionCount(): number { + return this.host?.activeSessionCount ?? 0; + } + + get isFishMode(): boolean { + return this.fishMode; + } + + get isVideoMuted(): boolean { + return this.videoMuted; + } + + async start(): Promise<{ port: number | null }> { + this.assertOpenable(); + await this.store.init(); + + // Hold the single-owner lock early (port 0 = not yet bound to a CDP port). + // Real port is written once ensureHost resolves a live endpoint. + this.releaseLock = await acquireInjectorLock(this.dataRoot, this.port ?? 0); + + if (!this.deferHostConnect) { + await this.ensureHost({ allowDiscover: true }); + await this.republishActive().catch((err) => { + this.onError?.(err instanceof Error ? err : new Error(String(err))); + }); + this.startWatchLoop(); + return { port: this.port }; + } + + // Fast path for tray: return immediately, connect + publish in background. + this.startWatchLoop(); + const startup = this.ensureHost({ allowDiscover: true }) + .then(() => this.republishActive()) + .catch((err) => { + this.onError?.(err instanceof Error ? err : new Error(String(err))); + }); + this.startupTask = startup; + void startup.finally(() => { + if (this.startupTask === startup) this.startupTask = null; + }); + return { port: this.port }; + } + + /** + * Resolve CDP port (if needed), open host sessions, and keep host non-null. + * Serialized — concurrent apply/watch share one connect attempt. + */ + private ensureHost(opts: { allowDiscover?: boolean } = {}): Promise { + const run = async () => { + if (this.closed) throw new CdpError("Session already stopped"); + + if (this.port == null) { + if (!(opts.allowDiscover ?? true) || !this.autoDiscover) { + throw new CdpError( + "No CDP port configured. Pass --port or enable auto-discover.", + ); + } + this.onStatus?.("Discovering loopback Codex CDP…"); + const best = await findBestCdpPort({ + requirePages: true, + timeoutMs: 450, + }); + if (!best) { + throw new CdpError( + "No healthy loopback Codex CDP endpoint found. Open Codex Desktop, then use tray 应用或重新应用.", + ); + } + if (this.closed) throw new CdpError("Session already stopped"); + this.port = best.port; + this.onStatus?.( + `Using CDP :${best.port} (${best.browser ?? "unknown"}; primaryPages=${best.primaryPages})`, + ); + } + + await probeCdp(this.port, "127.0.0.1", { timeoutMs: 800 }); + if (this.closed) throw new CdpError("Session already stopped"); + if (!this.host || this.host.port !== this.port) { + this.host?.close(); + this.host = this.createHost(this.port); + this.detachedVideoKey = ""; + } + const connectCurrentHost = async () => { + if (!this.host) throw new CdpError("Host is not connected"); + if (this.host.activeSessionCount === 0) { + await this.host.connect(); + } else { + await this.host.reconcileSessions(); + if (this.host.activeSessionCount === 0) { + await this.host.connect(); + } + } + }; + + try { + await connectCurrentHost(); + } catch (err) { + if (!(err instanceof CdpIdentityMismatchError) || this.port == null) { + throw err; + } + + // A normal Codex restart keeps the loopback port but replaces the + // Chromium browser identity. Drop every stale target and bind once to + // the freshly probed browser so the triggering user action can finish. + this.onStatus?.(`Codex CDP restarted on :${this.port}; reconnecting…`); + this.host?.close(); + this.host = this.createHost(this.port); + this.detachedVideoKey = ""; + this.lastPublishSessionKey = ""; + await connectCurrentHost(); + } + if (this.closed) { + this.host.close(); + throw new CdpError("Session already stopped"); + } + }; + + this.hostConnectChain = this.hostConnectChain.then(run, run); + return this.hostConnectChain; + } + + async apply(input: ApplyInput): Promise { + return this.#trackOperation(this.applyInternal(input)); + } + + private async applyInternal(input: ApplyInput): Promise { + if (this.closed || !this.releaseLock) { + throw new CdpError("Session is not started"); + } + if (this.userBusy) { + return { + ok: false, + error: "Another background apply is already in progress.", + rolledBack: false, + }; + } + this.userBusy = true; + try { + await this.ensureHost({ allowDiscover: true }); + if (!this.host || this.port == null) { + throw new CdpError("Host is not connected"); + } + const { cssText } = await loadRendererSource(); + const tx = new ApplyTransaction({ + store: this.store, + media: this.media, + host: this.host, + cssText, + verifyDeadlineMs: this.verifyDeadlineMs, + offline: false, + }); + const result = await tx.run(input); + if (result.ok) { + this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; + this.detachedVideoKey = + input.type === "video" + ? `${this.lastPublishSessionKey}:${result.generation}` + : ""; + // Manual apply leaves the previous theme binding — progress must not + // keep writing into a theme the user is no longer viewing. + this.activeThemeId = null; + this.lastProgressWriteSec = -1; + // clear always exits fish; other applies re-assert if still wanted. + if (input.type === "clear") { + this.fishMode = false; + } else if (this.fishMode) { + await this.host.setFishMode(true).catch(() => null); + } + // Mute preference is independent of clear; re-assert on live video. + if (!this.videoMuted || input.type === "video") { + await this.host.setMuted(this.videoMuted).catch(() => null); + } + await this.reassertBackgroundTone(); + } + return result; + } finally { + this.userBusy = false; + } + } + + async status(): Promise { + await this.store.init(); + const manifest = await this.store.readActiveManifest(); + return { + host: this.descriptor, + port: this.port, + sessions: this.host?.activeSessionCount ?? 0, + manifest, + mediaServer: + this.media.activeImage?.url ?? this.media.activeVideo?.url ?? null, + fish: this.fishMode, + muted: this.videoMuted, + tone: this.backgroundTone, + }; + } + + /** + * Toggle fish mode (摸鱼). Attribute-only in the renderer — no media rebuild. + * Not persisted across tray/process restarts. Requires an active background. + */ + async setFishMode(enabled: boolean): Promise<{ + ok: boolean; + fish: boolean; + sessions: number; + error?: string; + }> { + if (this.closed || !this.releaseLock) { + return { + ok: false, + fish: this.fishMode, + sessions: 0, + error: "Session is not started", + }; + } + const want = Boolean(enabled); + if (want) { + await this.store.init(); + const manifest = await this.store.readActiveManifest(); + if (!manifest.background) { + this.fishMode = false; + return { + ok: false, + fish: false, + sessions: 0, + error: "No active background. Apply an image or video first.", + }; + } + } + // Remember desired state even if host is momentarily down; watch will + // re-assert after the next successful publish. + this.fishMode = want; + try { + await this.ensureHost({ allowDiscover: true }); + } catch (err) { + return { + ok: false, + fish: this.fishMode, + sessions: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + if (!this.host) { + return { + ok: false, + fish: this.fishMode, + sessions: 0, + error: "Host is not connected", + }; + } + const result = await this.host.setFishMode(want); + // Host may refuse (e.g. clear media) — mirror that. + if (result.ok) this.fishMode = result.fish; + else if (!want) this.fishMode = false; + return result; + } + + /** + * Toggle background video mute. Attribute/property only — no media rebuild. + * Default muted. Not persisted across tray/process restarts. + * Independent of fish mode. If unmute is blocked by autoplay policy the + * video keeps playing muted and `blocked: true` is returned. + */ + async setMuted(muted: boolean): Promise<{ + ok: boolean; + muted: boolean; + blocked: boolean; + sessions: number; + error?: string; + }> { + if (this.closed || !this.releaseLock) { + return { + ok: false, + muted: this.videoMuted, + blocked: false, + sessions: 0, + error: "Session is not started", + }; + } + this.videoMuted = Boolean(muted); + try { + await this.ensureHost({ allowDiscover: true }); + } catch (err) { + // Preference kept for the next successful publish. + return { + ok: true, + muted: this.videoMuted, + blocked: false, + sessions: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + if (!this.host) { + return { + ok: true, + muted: this.videoMuted, + blocked: false, + sessions: 0, + }; + } + const result = await this.host.setMuted(this.videoMuted); + if (result.ok) this.videoMuted = result.muted; + return result; + } + + /** Change only the injected CSS overlay; media and generation are untouched. */ + async setBackgroundTone(tone: BackgroundTone): Promise<{ + ok: boolean; + tone: BackgroundTone; + sessions: number; + error?: string; + }> { + if (this.closed || !this.releaseLock) { + return { + ok: false, + tone: this.backgroundTone, + sessions: 0, + error: "Session is not started", + }; + } + this.backgroundTone = + tone === "light" || tone === "auto" ? tone : "dark"; + try { + await this.ensureHost({ allowDiscover: true }); + } catch (err) { + // Keep the preference for the next Codex connection. + return { + ok: true, + tone: this.backgroundTone, + sessions: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + if (!this.host || !this.host.setBackgroundTone) { + return { ok: true, tone: this.backgroundTone, sessions: 0 }; + } + const result = await this.host.setBackgroundTone(this.backgroundTone); + if (result.ok) this.backgroundTone = result.tone; + return result; + } + + /** + * Re-publish the currently active background into live sessions without + * bumping generation / re-importing media (tray "应用或重新应用"). + */ + async reapply(): Promise { + return this.#trackOperation(this.reapplyInternal()); + } + + private async reapplyInternal(): Promise { + if (this.closed || !this.releaseLock) { + throw new CdpError("Session is not started"); + } + if (this.userBusy) { + return { + ok: false, + error: "Another background apply is already in progress.", + rolledBack: false, + }; + } + this.userBusy = true; + try { + await this.ensureHost({ allowDiscover: true }); + if (!this.host || this.port == null) { + throw new CdpError("Host is not connected"); + } + const manifest = await this.store.readActiveManifest(); + await this.republishActive(); + const verify = await this.host.verify( + { + generation: manifest.generation, + media: manifest.background?.type ?? "clear", + }, + { deadlineMs: this.verifyDeadlineMs }, + ); + if (verify.status !== "pass") { + return { + ok: false, + error: `Live verify did not pass (${verify.status}): ${verify.reason}`, + rolledBack: false, + }; + } + this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; + return { + ok: true, + generation: manifest.generation, + mode: manifest.background?.type ?? "clear", + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + rolledBack: false, + }; + } finally { + this.userBusy = false; + } + } + + /** Persist the active image/video under a user-facing name. */ + async saveCurrentTheme(name: string): Promise { + return this.#trackOperation(this.saveCurrentThemeInternal(name)); + } + + private async saveCurrentThemeInternal(name: string): Promise { + if (this.closed || !this.releaseLock) { + throw new CdpError("Session is not started"); + } + await this.store.init(); + let videoPositionSec: number | null = null; + // Capture live progress at save time when a video is playing. + if (this.host) { + try { + const pos = await this.host.getPlaybackPosition(); + if (pos.ok && pos.hasVideo && Number.isFinite(pos.currentTime)) { + videoPositionSec = pos.currentTime; + } + } catch { + /* save without position */ + } + } + const theme = await this.store.saveCurrentTheme(name, { videoPositionSec }); + // Bind continuous writes to the freshly saved theme when it is video. + if (theme.type === "video") { + this.activeThemeId = theme.id; + this.lastProgressWriteSec = -1; + } else { + this.activeThemeId = null; + } + return theme; + } + + async listSavedThemes(): Promise { + await this.store.init(); + return this.store.listSavedThemes(); + } + + async deleteSavedTheme(themeId: string): Promise { + const id = String(themeId ?? "").trim(); + const deleted = await this.store.deleteSavedTheme(id); + if (deleted && this.activeThemeId === id) { + this.activeThemeId = null; + this.lastProgressWriteSec = -1; + } + return deleted; + } + + /** + * Restore a previously saved theme into active and publish to live sessions. + * Video themes resume at the last written position (invalid → 0). + * Binds continuous progress writes to this theme id. + */ + async useSavedTheme(themeId: string): Promise { + return this.#trackOperation(this.useSavedThemeInternal(themeId)); + } + + private async useSavedThemeInternal(themeId: string): Promise { + if (this.closed || !this.releaseLock) { + throw new CdpError("Session is not started"); + } + if (this.userBusy) { + return { + ok: false, + error: "Another background apply is already in progress.", + rolledBack: false, + }; + } + this.userBusy = true; + try { + const saved = await this.store.loadSavedTheme(themeId); + await this.ensureHost({ allowDiscover: true }); + if (!this.host || this.port == null) { + throw new CdpError("Host is not connected"); + } + const { cssText } = await loadRendererSource(); + const tx = new ApplyTransaction({ + store: this.store, + media: this.media, + host: this.host, + cssText, + verifyDeadlineMs: this.verifyDeadlineMs, + offline: false, + }); + const result = await tx.run(saved.input); + if (!result.ok) return result; + + // Bind progress only after disk, host apply and live verify all passed. + this.activeThemeId = + saved.input.type === "video" ? saved.themeId : null; + this.detachedVideoKey = + saved.input.type === "video" + ? `${this.port}:${this.host.activeSessionCount}:${result.generation}` + : ""; + this.lastProgressWriteSec = -1; + this.lastPublishSessionKey = `${this.port}:${this.host.activeSessionCount}`; + if (this.fishMode) { + await this.host.setFishMode(true).catch(() => null); + } + if (!this.videoMuted || saved.input.type === "video") { + await this.host.setMuted(this.videoMuted).catch(() => null); + } + await this.reassertBackgroundTone(); + return result; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + rolledBack: false, + }; + } finally { + this.userBusy = false; + } + } + + async stop(): Promise { + if (this.stopTask) return this.stopTask; + const task = this.stopExclusive(); + this.stopTask = task; + return task; + } + + private async stopExclusive(): Promise { + this.closed = true; + if (this.watchTimer) { + clearInterval(this.watchTimer); + this.watchTimer = null; + } + // Best-effort restore host UI before tearing down CDP (tray quit path). + if (this.fishMode && this.host) { + try { + await this.host.setFishMode(false); + } catch { + /* ignore */ + } + this.fishMode = false; + } + this.host?.close(); + const pending = [ + this.startupTask, + this.watchTask, + this.hostConnectChain, + ...this.activeOperations, + ].filter((value): value is Promise => Boolean(value)); + await Promise.allSettled(pending); + this.host?.close(); + this.host = null; + await this.media.close().catch(() => {}); + if (this.releaseLock) { + await this.releaseLock().catch(() => {}); + this.releaseLock = null; + } + } + + private createHost(port: number): CodexHostApplier { + const options: ConstructorParameters[0] = { + port, + requireAppProtocol: this.requireAppProtocol, + pollMs: Math.min(this.pollMs, 400), + }; + if (this.urlPrefix !== undefined) options.urlPrefix = this.urlPrefix; + return new CodexHostApplier(options); + } + + private startWatchLoop(): void { + if (this.watchTimer) return; + this.watchTimer = setInterval(() => { + const task = this.watchTick(); + this.watchTask = task; + void task.finally(() => { + if (this.watchTask === task) this.watchTask = null; + }); + }, this.pollMs); + this.watchTimer.unref?.(); + } + + private async watchTick(): Promise { + // Skip while a user apply is running, or if a previous watch tick is open. + // Never hold userBusy here — that made tray image/video switches fail with + // "already in progress" and forced multi-retry. + if (this.closed || this.userBusy || this.watchBusy || !this.releaseLock) { + return; + } + this.watchBusy = true; + try { + // Soft connect: if Codex is not up yet, stay quiet until the next tick + // (or until the user hits 应用或重新应用 which launches the host). + try { + await this.ensureHost({ allowDiscover: true }); + } catch { + return; + } + if (!this.host || this.port == null) return; + + await this.host.reconcileSessions(); + if (this.host.activeSessionCount === 0) { + await this.host.connect(); + } + // After Codex restart session ids change. Always restage fresh media URLs + // for new sessions — reapplyLast would re-inject dead/stale loopback URLs + // and surface as "fail fetch" in the renderer. + // When the session set is stable, only heal missing/unhealthy runtimes. + // Full reapply every poll was the main image↔video alternate flash source + // (session 019fa31c / Dream Skin watch path). + const sessionKey = `${this.port}:${this.host.activeSessionCount}`; + if ( + sessionKey !== this.lastPublishSessionKey || + !this.host.lastApplied + ) { + await this.republishActive(); + this.lastPublishSessionKey = sessionKey; + } else { + await this.host.reapplyLast(); + } + // Continuous per-theme progress write (video only, bound theme only). + await this.persistBoundThemeProgress().catch(() => {}); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.onError?.(error); + if (err instanceof CdpIdentityMismatchError && this.port != null) { + try { + this.host?.close(); + this.host = this.createHost(this.port); + this.lastPublishSessionKey = ""; + await this.ensureHost({ allowDiscover: false }); + await this.republishActive(); + this.lastPublishSessionKey = `${this.port}:${this.host?.activeSessionCount ?? 0}`; + } catch (re) { + this.onError?.(re instanceof Error ? re : new Error(String(re))); + } + } + } finally { + this.watchBusy = false; + } + } + + #trackOperation(operation: Promise): Promise { + this.activeOperations.add(operation); + void operation.then( + () => this.activeOperations.delete(operation), + () => this.activeOperations.delete(operation), + ); + return operation; + } + + /** + * While a saved video theme is active, periodically write currentTime into + * that theme's theme.json. Fail-soft; never blocks user applies. + */ + private async persistBoundThemeProgress(): Promise { + if ( + !this.activeThemeId || + !this.host || + this.progressWriteInFlight || + this.userBusy + ) { + return; + } + const now = Date.now(); + // ~2s cadence is enough for resume and keeps disk quiet. + if (now - this.lastProgressWriteAt < 2_000) return; + + this.progressWriteInFlight = true; + try { + const pos = await this.host.getPlaybackPosition(); + if (!pos.ok || !pos.hasVideo) return; + let t = Number(pos.currentTime); + if (!Number.isFinite(t) || t < 0) return; + // Near end: store 0 so next restore starts cleanly (product: invalid → 0). + if (pos.duration > 0 && t >= pos.duration - 0.25) { + t = 0; + } + // Skip tiny moves (<0.5s) to cut writes further. + if ( + this.lastProgressWriteSec >= 0 && + Math.abs(t - this.lastProgressWriteSec) < 0.5 && + !(t === 0 && this.lastProgressWriteSec !== 0) + ) { + this.lastProgressWriteAt = now; + return; + } + const result = await this.store.updateSavedThemeVideoPosition( + this.activeThemeId, + t, + ); + if (result.ok) { + this.lastProgressWriteAt = now; + this.lastProgressWriteSec = + result.positionSec != null ? result.positionSec : t; + } else if (result.error === "Saved theme not found.") { + // Theme deleted under us — stop binding. + this.activeThemeId = null; + } + } finally { + this.progressWriteInFlight = false; + } + } + + private async republishActive(): Promise { + if (!this.host) return; + const manifest = await this.store.readActiveManifest(); + const { cssText } = await loadRendererSource(); + + if (!manifest.background) { + this.fishMode = false; + this.activeThemeId = null; + this.detachedVideoKey = ""; + await this.host.apply( + await buildHostApplyPayload(this.store, manifest, null, cssText), + ); + await this.reassertBackgroundTone(); + await this.media.commit(null); + return; + } + + const imagePath = resolveBackgroundImagePath( + this.store.paths.activeDir, + manifest.background, + ); + if (!imagePath) throw new Error("Background has no image source."); + // Codex CSP requires data:/blob:; loopback is secondary only. + const imageHandle = await this.media.stage(imagePath, { + validation: + manifest.background.type === "image" && isLocalBackgroundSource(manifest.background) + ? "fast" + : "full", + }); + let videoHandle = null as Awaited>; + + if (manifest.background.type === "video") { + const videoPath = await this.store.prepareRuntimeVideo(manifest); + if (!videoPath) throw new Error("Video background has no video source."); + videoHandle = await this.media.stage(videoPath, { + validation: isLocalBackgroundSource(manifest.background) ? "fast" : "full", + }); + // Codex Desktop primary: CDP file-input → blob: (Dream Skin path). + // Skip multi-MB dataUrl — host applier attaches the local file. + } + + let committed = false; + let runtimeVideoPath: string | null = null; + const videoKey = + manifest.background.type === "video" + ? `${this.port}:${this.host.activeSessionCount}:${manifest.generation}` + : ""; + const forceVideoRebuild = + Boolean(videoKey) && this.detachedVideoKey !== videoKey; + const previousDetachedVideoKey = this.detachedVideoKey; + if (forceVideoRebuild) this.detachedVideoKey = videoKey; + try { + const payload = await buildHostApplyPayload( + this.store, + manifest, + { image: imageHandle, video: videoHandle }, + cssText, + ); + runtimeVideoPath = payload.video?.localPath ?? null; + await this.host.apply(payload, { forceRebuild: forceVideoRebuild }); + await this.media.commit({ image: imageHandle, video: videoHandle }); + await this.store.pruneRuntimeMedia(runtimeVideoPath); + committed = true; + } catch (error) { + if ( + forceVideoRebuild && + this.detachedVideoKey === videoKey + ) { + this.detachedVideoKey = previousDetachedVideoKey; + } + throw error; + } finally { + if (!committed) { + await this.media.abort(videoHandle).catch(() => {}); + await this.media.abort(imageHandle).catch(() => {}); + } + } + // Watch / reapply must not drop fish / mute prefs after a reinject. + if (this.fishMode) { + await this.host.setFishMode(true).catch(() => null); + } + await this.host.setMuted(this.videoMuted).catch(() => null); + await this.reassertBackgroundTone(); + } + + private async reassertBackgroundTone(): Promise { + if (!this.host?.setBackgroundTone) return; + await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); + } + + private assertOpenable(): void { + if (this.closed) throw new CdpError("Session already stopped"); + if (this.releaseLock) throw new CdpError("Session already started"); + } +} diff --git a/packages/adapter-dsh/src/bridge.ts b/packages/adapter-dsh/src/bridge.ts index 5ea8a4e..019cc19 100644 --- a/packages/adapter-dsh/src/bridge.ts +++ b/packages/adapter-dsh/src/bridge.ts @@ -1,294 +1,314 @@ -import type { - BackgroundTone, - HostApplier, - HostApplyPayload, - VerifyExpectation, - VerifyResult, -} from "@beauticode/core"; - -export interface DshBridgeStatus { - ok: true; - connectedClients: number; - current: { generation: number; media: "image" | "video" | "clear" } | null; - readyClients: number; - failedClients: number; - visibleClients: number; - modeReadyClients: number; - blockedClients: number; - resolvedTone: "dark" | "light" | null; - modes: { fish: boolean; muted: boolean; tone: BackgroundTone }; - playback: { - currentTime: number; - duration: number; - hasVideo: boolean; - muted: boolean; - paused: boolean; - blocked: boolean; - } | null; -} - -export interface DshHostApplierOptions { - baseUrl?: string; - token: string; - requestTimeoutMs?: number; - pollMs?: number; -} - +import type { + BackgroundTone, + HostApplier, + HostApplyPayload, + VerifyExpectation, + VerifyResult, +} from "@beauticode/core"; + +export interface DshBridgeStatus { + ok: true; + connectedClients: number; + current: { generation: number; media: "image" | "video" | "clear" } | null; + readyClients: number; + failedClients: number; + lastRenderError?: string | null; + visibleClients: number; + modeReadyClients: number; + blockedClients: number; + resolvedTone: "dark" | "light" | null; + modes: { fish: boolean; muted: boolean; tone: BackgroundTone }; + playback: { + currentTime: number; + duration: number; + hasVideo: boolean; + muted: boolean; + paused: boolean; + blocked: boolean; + } | null; +} + +export interface DshHostApplierOptions { + baseUrl?: string; + token: string; + requestTimeoutMs?: number; + pollMs?: number; +} + export function normalizeDshBaseUrl(value: string): URL { - const url = new URL(value); - const hostname = url.hostname.toLowerCase(); - if ( - url.protocol !== "http:" || - !["127.0.0.1", "localhost", "[::1]"].includes(hostname) || - url.username || - url.password || - (url.pathname !== "" && url.pathname !== "/") || - url.search || - url.hash - ) { - throw new Error("DeepSeek Harness URL must be loopback HTTP."); - } - url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + const url = new URL(value); + const hostname = url.hostname.toLowerCase(); + if ( + url.protocol !== "http:" || + !["127.0.0.1", "localhost", "[::1]"].includes(hostname) || + url.username || + url.password || + (url.pathname !== "" && url.pathname !== "/") || + url.search || + url.hash + ) { + throw new Error("DeepSeek Harness URL must be loopback HTTP."); + } + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; return url; } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export class DshHostApplier implements HostApplier { - readonly baseUrl: URL; - readonly requestTimeoutMs: number; - readonly pollMs: number; - private token: string; - private lastStatus: DshBridgeStatus | null = null; - - constructor(opts: DshHostApplierOptions) { - this.baseUrl = normalizeDshBaseUrl(opts.baseUrl ?? "http://127.0.0.1:3080"); - this.token = opts.token; - this.requestTimeoutMs = opts.requestTimeoutMs ?? 3_000; - this.pollMs = opts.pollMs ?? 150; - } - - get origin(): string { - return this.baseUrl.origin; - } - - get activeSessionCount(): number { - return this.lastStatus?.connectedClients ?? 0; - } - - async status(): Promise { - try { - const status = await this.#request("status", { method: "GET" }); - if (!status || status.ok !== true || !Number.isInteger(status.connectedClients)) { - throw new Error("DeepSeek Harness bridge returned an invalid status."); - } - this.lastStatus = status; - return status; - } catch (error) { - this.lastStatus = null; - throw error; - } - } - - async apply(payload: HostApplyPayload): Promise { - if (payload.media !== "clear" && !payload.imageUrl) { - throw new Error("DeepSeek Harness background apply requires a loopback poster URL."); - } - if (payload.media === "video" && !payload.video?.srcUrl) { - throw new Error("DeepSeek Harness video apply requires a loopback MP4 URL."); - } - const body: Record = { - generation: payload.generation, - media: payload.media, - imageUrl: payload.media === "clear" ? null : payload.imageUrl, - videoUrl: payload.media === "video" ? payload.video?.srcUrl : null, - startAt: - payload.media === "video" && Number.isFinite(payload.video?.startAt) - ? Math.max(0, Number(payload.video?.startAt)) - : null, - }; - if (payload.atmosphere?.preset) body.atmosphere = payload.atmosphere; - await this.#request("apply", { - method: "POST", - body: JSON.stringify(body), - }); - } - - async setFishMode(enabled: boolean): Promise<{ - ok: boolean; - fish: boolean; - sessions: number; - error?: string; - }> { - const result = await this.#setMode({ fish: Boolean(enabled) }); - return { ok: result.ok, fish: Boolean(enabled), sessions: result.sessions, ...(result.error ? { error: result.error } : {}) }; - } - - async setMuted(muted: boolean): Promise<{ - ok: boolean; - muted: boolean; - blocked: boolean; - sessions: number; - error?: string; - }> { - const result = await this.#setMode({ muted: Boolean(muted) }); - return { - ok: result.ok, - muted: Boolean(muted), - blocked: result.blocked, - sessions: result.sessions, - ...(result.error ? { error: result.error } : {}), - }; - } - - async setBackgroundTone(tone: BackgroundTone): Promise<{ - ok: boolean; - tone: BackgroundTone; - sessions: number; - error?: string; - }> { - const normalized = tone === "light" || tone === "auto" ? tone : "dark"; - const result = await this.#setMode({ tone: normalized }); - return { ok: result.ok, tone: normalized, sessions: result.sessions, ...(result.error ? { error: result.error } : {}) }; - } - - async getPlaybackPosition(): Promise<{ - ok: boolean; - currentTime: number; - duration: number; - hasVideo: boolean; - }> { - const status = await this.status(); - const playback = status.playback; - if (!playback?.hasVideo) { - return { ok: false, currentTime: 0, duration: 0, hasVideo: false }; - } - return { - ok: true, - currentTime: Number.isFinite(playback.currentTime) ? playback.currentTime : 0, - duration: Number.isFinite(playback.duration) ? playback.duration : 0, - hasVideo: true, - }; - } - - async verify( - expected: VerifyExpectation, - opts: { deadlineMs: number }, - ): Promise { - const deadline = Date.now() + Math.max(0, opts.deadlineMs); - let last: DshBridgeStatus | null = null; - let lastError = "DeepSeek Harness bridge is unavailable."; - do { - try { - last = await this.status(); - const currentMatches = - last.current?.generation === expected.generation && - last.current.media === expected.media; - if (currentMatches && last.readyClients > 0) { - return { - status: "pass", - reason: "DeepSeek Harness client acknowledged the background.", - details: { ...last }, - }; - } - if (currentMatches && last.failedClients > 0 && last.readyClients === 0) { - return { - status: "fail", - reason: "DeepSeek Harness client failed to render the background.", - details: { ...last }, - }; - } - lastError = - last.connectedClients === 0 - ? "No DeepSeek Harness browser client is connected." - : "DeepSeek Harness client has not acknowledged this generation."; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - if (Date.now() >= deadline) break; - await delay(Math.min(this.pollMs, Math.max(0, deadline - Date.now()))); - } while (true); - return { - status: "inconclusive", - reason: lastError, - ...(last ? { details: { ...last } } : {}), - }; - } - - async #request( - route: "apply" | "mode" | "status", - init: RequestInit, - ): Promise { - const endpoint = new URL(`__beauticode/${route}`, this.baseUrl); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - const response = await fetch(endpoint, { - ...init, - signal: controller.signal, - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - ...(init.headers ?? {}), - }, - }); - const body = (await response.json().catch(() => null)) as - | { error?: unknown } - | null; - if (!response.ok) { - const detail = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`; - throw new Error(`DeepSeek Harness bridge request failed: ${detail}`); - } - return body as T; - } catch (error) { - if ((error as Error)?.name === "AbortError") { - throw new Error("DeepSeek Harness bridge request timed out."); - } - throw error; - } finally { - clearTimeout(timer); - } - } - - async #setMode(change: Partial<{ fish: boolean; muted: boolean; tone: BackgroundTone }>): Promise<{ - ok: boolean; - sessions: number; - blocked: boolean; - error?: string; - }> { - await this.#request("mode", { method: "POST", body: JSON.stringify(change) }); - const deadline = Date.now() + this.requestTimeoutMs; - let last: DshBridgeStatus | null = null; - do { - try { - last = await this.status(); - if (last.connectedClients === 0) { - return { ok: true, sessions: 0, blocked: false }; - } - if (last.modeReadyClients > 0) { - return { - ok: true, - sessions: last.modeReadyClients, - blocked: last.blockedClients > 0, - }; - } - } catch (error) { - if (Date.now() >= deadline) { - return { ok: false, sessions: 0, blocked: false, error: error instanceof Error ? error.message : String(error) }; - } - } - if (Date.now() >= deadline) break; - await delay(Math.min(this.pollMs, Math.max(0, deadline - Date.now()))); - } while (true); - return { - ok: false, - sessions: 0, - blocked: false, - error: last?.connectedClients - ? "DeepSeek Harness client did not acknowledge the mode change." - : "No DeepSeek Harness browser client is connected.", - }; - } +export function dshTrustedOrigins(value: string | URL): string[] { + const baseUrl = normalizeDshBaseUrl(String(value)); + const port = baseUrl.port ? `:${baseUrl.port}` : ""; + return [ + ...new Set([ + baseUrl.origin, + new URL(`http://127.0.0.1${port}`).origin, + new URL(`http://localhost${port}`).origin, + new URL(`http://[::1]${port}`).origin, + ]), + ]; } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class DshHostApplier implements HostApplier { + readonly baseUrl: URL; + readonly requestTimeoutMs: number; + readonly pollMs: number; + private token: string; + private lastStatus: DshBridgeStatus | null = null; + + constructor(opts: DshHostApplierOptions) { + this.baseUrl = normalizeDshBaseUrl(opts.baseUrl ?? "http://127.0.0.1:3080"); + this.token = opts.token; + this.requestTimeoutMs = opts.requestTimeoutMs ?? 3_000; + this.pollMs = opts.pollMs ?? 150; + } + + get origin(): string { + return this.baseUrl.origin; + } + + get activeSessionCount(): number { + return this.lastStatus?.connectedClients ?? 0; + } + + async status(): Promise { + try { + const status = await this.#request("status", { method: "GET" }); + if (!status || status.ok !== true || !Number.isInteger(status.connectedClients)) { + throw new Error("DeepSeek Harness bridge returned an invalid status."); + } + this.lastStatus = status; + return status; + } catch (error) { + this.lastStatus = null; + throw error; + } + } + + async apply(payload: HostApplyPayload): Promise { + if (payload.media !== "clear" && !payload.imageUrl) { + throw new Error("DeepSeek Harness background apply requires a loopback poster URL."); + } + if (payload.media === "video" && !payload.video?.srcUrl) { + throw new Error("DeepSeek Harness video apply requires a loopback MP4 URL."); + } + const body: Record = { + generation: payload.generation, + media: payload.media, + imageUrl: payload.media === "clear" ? null : payload.imageUrl, + videoUrl: payload.media === "video" ? payload.video?.srcUrl : null, + startAt: + payload.media === "video" && Number.isFinite(payload.video?.startAt) + ? Math.max(0, Number(payload.video?.startAt)) + : null, + }; + if (payload.atmosphere?.preset) body.atmosphere = payload.atmosphere; + await this.#request("apply", { + method: "POST", + body: JSON.stringify(body), + }); + } + + async setFishMode(enabled: boolean): Promise<{ + ok: boolean; + fish: boolean; + sessions: number; + error?: string; + }> { + const result = await this.#setMode({ fish: Boolean(enabled) }); + return { ok: result.ok, fish: Boolean(enabled), sessions: result.sessions, ...(result.error ? { error: result.error } : {}) }; + } + + async setMuted(muted: boolean): Promise<{ + ok: boolean; + muted: boolean; + blocked: boolean; + sessions: number; + error?: string; + }> { + const result = await this.#setMode({ muted: Boolean(muted) }); + return { + ok: result.ok, + muted: Boolean(muted), + blocked: result.blocked, + sessions: result.sessions, + ...(result.error ? { error: result.error } : {}), + }; + } + + async setBackgroundTone(tone: BackgroundTone): Promise<{ + ok: boolean; + tone: BackgroundTone; + sessions: number; + error?: string; + }> { + const normalized = tone === "light" || tone === "auto" ? tone : "dark"; + const result = await this.#setMode({ tone: normalized }); + return { ok: result.ok, tone: normalized, sessions: result.sessions, ...(result.error ? { error: result.error } : {}) }; + } + + async getPlaybackPosition(): Promise<{ + ok: boolean; + currentTime: number; + duration: number; + hasVideo: boolean; + }> { + const status = await this.status(); + const playback = status.playback; + if (!playback?.hasVideo) { + return { ok: false, currentTime: 0, duration: 0, hasVideo: false }; + } + return { + ok: true, + currentTime: Number.isFinite(playback.currentTime) ? playback.currentTime : 0, + duration: Number.isFinite(playback.duration) ? playback.duration : 0, + hasVideo: true, + }; + } + + async verify( + expected: VerifyExpectation, + opts: { deadlineMs: number }, + ): Promise { + const deadline = Date.now() + Math.max(0, opts.deadlineMs); + let last: DshBridgeStatus | null = null; + let lastError = "DeepSeek Harness bridge is unavailable."; + do { + try { + last = await this.status(); + const currentMatches = + last.current?.generation === expected.generation && + last.current.media === expected.media; + if (currentMatches && last.readyClients > 0) { + return { + status: "pass", + reason: "DeepSeek Harness client acknowledged the background.", + details: { ...last }, + }; + } + if ( + currentMatches && + last.failedClients > 0 && + last.readyClients === 0 && + typeof last.lastRenderError === "string" && + last.lastRenderError + ) { + return { + status: "fail", + reason: last.lastRenderError, + details: { ...last }, + }; + } + lastError = + last.connectedClients === 0 + ? "No DeepSeek Harness browser client is connected." + : "DeepSeek Harness client has not acknowledged this generation."; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + if (Date.now() >= deadline) break; + await delay(Math.min(this.pollMs, Math.max(0, deadline - Date.now()))); + } while (true); + return { + status: "inconclusive", + reason: lastError, + ...(last ? { details: { ...last } } : {}), + }; + } + + async #request( + route: "apply" | "mode" | "status", + init: RequestInit, + ): Promise { + const endpoint = new URL(`__beauticode/${route}`, this.baseUrl); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + try { + const response = await fetch(endpoint, { + ...init, + signal: controller.signal, + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + const body = (await response.json().catch(() => null)) as + | { error?: unknown } + | null; + if (!response.ok) { + const detail = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`; + throw new Error(`DeepSeek Harness bridge request failed: ${detail}`); + } + return body as T; + } catch (error) { + if ((error as Error)?.name === "AbortError") { + throw new Error("DeepSeek Harness bridge request timed out."); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + async #setMode(change: Partial<{ fish: boolean; muted: boolean; tone: BackgroundTone }>): Promise<{ + ok: boolean; + sessions: number; + blocked: boolean; + error?: string; + }> { + await this.#request("mode", { method: "POST", body: JSON.stringify(change) }); + const deadline = Date.now() + this.requestTimeoutMs; + let last: DshBridgeStatus | null = null; + do { + try { + last = await this.status(); + if (last.connectedClients === 0) { + return { ok: true, sessions: 0, blocked: false }; + } + if (last.modeReadyClients > 0) { + return { + ok: true, + sessions: last.modeReadyClients, + blocked: last.blockedClients > 0, + }; + } + } catch (error) { + if (Date.now() >= deadline) { + return { ok: false, sessions: 0, blocked: false, error: error instanceof Error ? error.message : String(error) }; + } + } + if (Date.now() >= deadline) break; + await delay(Math.min(this.pollMs, Math.max(0, deadline - Date.now()))); + } while (true); + return { + ok: false, + sessions: 0, + blocked: false, + error: last?.connectedClients + ? "DeepSeek Harness client did not acknowledge the mode change." + : "No DeepSeek Harness browser client is connected.", + }; + } +} diff --git a/packages/adapter-dsh/src/index.ts b/packages/adapter-dsh/src/index.ts index 851f79b..06f7eed 100644 --- a/packages/adapter-dsh/src/index.ts +++ b/packages/adapter-dsh/src/index.ts @@ -1,14 +1,15 @@ export { DshHostApplier, + dshTrustedOrigins, normalizeDshBaseUrl, - type DshBridgeStatus, - type DshHostApplierOptions, -} from "./bridge.js"; -export { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js"; -export { DshSession, type DshSessionOptions } from "./session.js"; -export { - DSH_BRIDGE_TOKEN_FILE, - bridgeTokenPath, - ensureBridgeToken, -} from "./token.js"; -export { toChineseErrorMessage } from "@beauticode/core"; + type DshBridgeStatus, + type DshHostApplierOptions, +} from "./bridge.js"; +export { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js"; +export { DshSession, type DshSessionOptions } from "./session.js"; +export { + DSH_BRIDGE_TOKEN_FILE, + bridgeTokenPath, + ensureBridgeToken, +} from "./token.js"; +export { toChineseErrorMessage } from "@beauticode/core"; diff --git a/packages/adapter-dsh/src/session.ts b/packages/adapter-dsh/src/session.ts index ff5a3f5..a32a058 100644 --- a/packages/adapter-dsh/src/session.ts +++ b/packages/adapter-dsh/src/session.ts @@ -1,577 +1,646 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { - ApplyTransaction, - BackgroundStore, - MediaServerController, - buildHostApplyPayload, - defaultDataRoot, - resolveSessionBundledThemes, - type ApplyInput, - type ApplyResult, - type BackgroundTone, - type HostSession, - type HostSessionStatus, - type SavedThemeInfo, -} from "@beauticode/core"; -import { DshHostApplier, normalizeDshBaseUrl } from "./bridge.js"; -import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js"; -import { acquireDshInjectorLock } from "./injector-lock.js"; -import { ensureBridgeToken } from "./token.js"; -import { trayHandoffRequested } from "./tray-handoff.js"; - -export interface DshSessionOptions { - baseUrl?: string; - dataRoot?: string; - verifyDeadlineMs?: number; - pollMs?: number; - onError?: (err: Error) => void; - onStatus?: (msg: string) => void; - /** - * When true (plugin in-process session), stop if the tray claims the data - * root. The tray session-host must leave this false — it writes the claim. - */ - honorTrayHandoff?: boolean; - /** Pin the shipped 画窗 theme in 已保存主题. Default true. */ - bundledGallery?: boolean; - bundledGalleryImagePath?: string; -} - -export class DshSession implements HostSession { - readonly descriptor = DSH_HOST_DESCRIPTOR; - readonly cdpPort = null; - readonly dataRoot: string; - readonly verifyDeadlineMs: number; - readonly pollMs: number; - readonly honorTrayHandoff: boolean; - readonly baseUrl: URL; - - private store: BackgroundStore; - private media: MediaServerController; - private host: DshHostApplier | null = null; - private releaseLock: (() => Promise) | null = null; - private watchTimer: ReturnType | null = null; - private handoffTimer: ReturnType | null = null; - private watchTask: Promise | null = null; - private stopTask: Promise | null = null; - private activeOperations = new Set>(); - private closed = false; - private userBusy = false; - private onError: ((err: Error) => void) | null; - private onStatus: ((msg: string) => void) | null; - private lastWatchError = ""; - private fishMode = false; - private videoMuted = true; - private backgroundTone: BackgroundTone = "auto"; - private activeThemeId: string | null = null; - private lastProgressWriteAt = 0; - private lastProgressWriteSec = -1; - private progressWriteInFlight = false; - - constructor(opts: DshSessionOptions = {}) { - this.dataRoot = opts.dataRoot ?? defaultDataRoot(); - this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000; - this.pollMs = opts.pollMs ?? 2_000; - this.honorTrayHandoff = opts.honorTrayHandoff !== false; - this.baseUrl = normalizeDshBaseUrl(opts.baseUrl ?? "http://127.0.0.1:3080"); - this.store = new BackgroundStore({ - root: this.dataRoot, - bundledThemes: resolveSessionBundledThemes({ - enabled: opts.bundledGallery, - imagePath: opts.bundledGalleryImagePath, - searchRoots: [ - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."), - process.cwd(), - ], - }), - }); +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ApplyTransaction, + BackgroundStore, + MediaServerController, + buildHostApplyPayload, + defaultDataRoot, + isLocalBackgroundSource, + resolveSessionBundledThemes, + resolveBackgroundImagePath, + type ApplyInput, + type ApplyResult, + type BackgroundTone, + type HostSession, + type HostSessionStatus, + type SavedThemeInfo, +} from "@beauticode/core"; +import { DshHostApplier, dshTrustedOrigins, normalizeDshBaseUrl } from "./bridge.js"; +import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js"; +import { acquireDshInjectorLock } from "./injector-lock.js"; +import { ensureBridgeToken } from "./token.js"; +import { trayHandoffRequested } from "./tray-handoff.js"; + +export interface DshSessionOptions { + baseUrl?: string; + dataRoot?: string; + verifyDeadlineMs?: number; + pollMs?: number; + onError?: (err: Error) => void; + onStatus?: (msg: string) => void; + /** + * When true (plugin in-process session), stop if the tray claims the data + * root. The tray session-host must leave this false — it writes the claim. + */ + honorTrayHandoff?: boolean; + /** Pin the shipped 画窗 theme in 已保存主题. Default true. */ + bundledGallery?: boolean; + bundledGalleryImagePath?: string; +} + +export type ApplyAndSaveThemeResult = + | (Extract & { theme: SavedThemeInfo }) + | Extract; + +type ApplyExecutionResult = + | (Extract & { theme?: SavedThemeInfo }) + | Extract; + +export class DshSession implements HostSession { + readonly descriptor = DSH_HOST_DESCRIPTOR; + readonly cdpPort = null; + readonly dataRoot: string; + readonly verifyDeadlineMs: number; + readonly pollMs: number; + readonly honorTrayHandoff: boolean; + readonly baseUrl: URL; + + private store: BackgroundStore; + private media: MediaServerController; + private host: DshHostApplier | null = null; + private releaseLock: (() => Promise) | null = null; + private watchTimer: ReturnType | null = null; + private handoffTimer: ReturnType | null = null; + private watchTask: Promise | null = null; + private stopTask: Promise | null = null; + private activeOperations = new Set>(); + private closed = false; + private userBusy = false; + private onError: ((err: Error) => void) | null; + private onStatus: ((msg: string) => void) | null; + private lastWatchError = ""; + private fishMode = false; + private videoMuted = true; + private backgroundTone: BackgroundTone = "auto"; + private activeThemeId: string | null = null; + private lastProgressWriteAt = 0; + private lastProgressWriteSec = -1; + private progressWriteInFlight = false; + + constructor(opts: DshSessionOptions = {}) { + this.dataRoot = opts.dataRoot ?? defaultDataRoot(); + this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000; + this.pollMs = opts.pollMs ?? 2_000; + this.honorTrayHandoff = opts.honorTrayHandoff !== false; + this.baseUrl = normalizeDshBaseUrl(opts.baseUrl ?? "http://127.0.0.1:3080"); + this.store = new BackgroundStore({ + root: this.dataRoot, + bundledThemes: resolveSessionBundledThemes({ + enabled: opts.bundledGallery, + imagePath: opts.bundledGalleryImagePath, + searchRoots: [ + path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."), + process.cwd(), + ], + }), + }); this.media = new MediaServerController({ enabled: true, - trustedOrigins: [this.baseUrl.origin], - }); - this.onError = opts.onError ?? null; - this.onStatus = opts.onStatus ?? null; - } - - get isBusy(): boolean { - return this.userBusy; - } - - get isOpen(): boolean { - return !this.closed && this.releaseLock != null; - } - - get isHostReady(): boolean { - return (this.host?.activeSessionCount ?? 0) > 0; - } - - async start(): Promise<{ port: number | null }> { - if (this.closed) throw new Error("Session already stopped"); - if (this.releaseLock) throw new Error("Session already started"); - await this.store.init(); - this.releaseLock = await acquireDshInjectorLock(this.dataRoot); - try { - const token = await ensureBridgeToken(this.dataRoot); - this.host = new DshHostApplier({ - baseUrl: this.baseUrl.href, - token, - pollMs: Math.min(250, Math.max(50, Math.floor(this.pollMs / 4))), - }); - } catch (error) { - await this.releaseLock().catch(() => {}); - this.releaseLock = null; - throw error; - } - this.startWatchLoop(); - if (this.honorTrayHandoff) { - this.startHandoffLoop(); - void this.yieldToTrayIfRequested(); - } - void this.watchOnce(); - return { port: this.bridgePort() }; - } - - async apply(input: ApplyInput): Promise { - return this.trackOperation(this.applyInternal(input)); - } - - private async applyInternal(input: ApplyInput): Promise { - if (!this.releaseLock || this.closed || !this.host) { - throw new Error("Session is not started"); - } - if (this.userBusy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.userBusy = true; - try { - const tx = new ApplyTransaction({ - store: this.store, - media: this.media, - host: this.host, - verifyDeadlineMs: this.verifyDeadlineMs, - offline: false, - }); - const result = await tx.run(input); - if (result.ok) { - this.activeThemeId = null; - this.lastProgressWriteSec = -1; - if (input.type === "clear") { - this.fishMode = false; - } else if (this.fishMode) { - await this.host.setFishMode(true).catch(() => null); - } - if (!this.videoMuted || input.type === "video") { - await this.host.setMuted(this.videoMuted).catch(() => null); - } - if (input.type === "image" && input.effects?.preset === "infernal") { - this.backgroundTone = "dark"; - } else if (input.type === "image" && input.effects?.preset === "internal") { - this.backgroundTone = "light"; - } - await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); - } - return result; - } finally { - this.userBusy = false; - } - } - - async reapply(): Promise { - return this.trackOperation(this.reapplyInternal()); - } - - private async reapplyInternal(): Promise { - if (!this.releaseLock || this.closed || !this.host) { - throw new Error("Session is not started"); - } - if (this.userBusy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.userBusy = true; - let stagedImage = null; - let stagedVideo = null; - try { - const manifest = await this.store.readActiveManifest(); - let resumeAt: number | null = null; - if (manifest.background?.type === "video") { - try { - const position = await this.host.getPlaybackPosition(); - if (position.ok && position.hasVideo && Number.isFinite(position.currentTime)) { - resumeAt = Math.max(0, position.currentTime); - } - } catch { - /* Fall back to the bound saved theme below. */ - } - if (resumeAt == null && this.activeThemeId) { - resumeAt = await this.store.getSavedThemeVideoPosition(this.activeThemeId); - } - } - if (manifest.background) { - stagedImage = await this.media.stage( - path.join(this.store.paths.activeDir, manifest.background.image), - ); - if (manifest.background.type === "video" && manifest.background.video) { - const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest); - if (!runtimeVideoPath) { - throw new Error("DSH video reapply requires a detached runtime copy."); - } - stagedVideo = await this.media.stage(runtimeVideoPath); - } - } - const staged = { image: stagedImage, video: stagedVideo }; - const payload = await buildHostApplyPayload(this.store, manifest, staged, ""); - if (payload.video && resumeAt != null) payload.video.startAt = resumeAt; - await this.host.apply(payload); - const verify = await this.host.verify( - { - generation: manifest.generation, - media: manifest.background?.type ?? "clear", - }, - { deadlineMs: this.verifyDeadlineMs }, - ); - if (verify.status !== "pass") { - await this.media.abort(stagedVideo); - await this.media.abort(stagedImage); - stagedVideo = null; - stagedImage = null; - return { - ok: false, - error: `Live verify did not pass (${verify.status}): ${verify.reason}`, - rolledBack: false, - }; - } - await this.media.commit(staged); - stagedVideo = null; - stagedImage = null; - if (!manifest.background) { - this.fishMode = false; - this.activeThemeId = null; - } else if (this.fishMode) { - await this.host.setFishMode(true).catch(() => null); - } - await this.host.setMuted(this.videoMuted).catch(() => null); - await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); - return { - ok: true, - generation: manifest.generation, - mode: manifest.background?.type ?? "clear", - }; - } catch (error) { - await this.media.abort(stagedVideo); - await this.media.abort(stagedImage); - return { - ok: false, - error: error instanceof Error ? error.message : String(error), - rolledBack: false, - }; - } finally { - this.userBusy = false; - } - } - - async status(): Promise { - const manifest = await this.store.readActiveManifest(); - if (this.host) await this.host.status().catch(() => null); - return { - host: this.descriptor, - port: this.bridgePort(), - sessions: this.host?.activeSessionCount ?? 0, - manifest, - mediaServer: this.media.activeImage?.url ?? this.media.activeVideo?.url ?? null, - fish: this.fishMode, - muted: this.videoMuted, - tone: this.backgroundTone, - }; - } - - async saveCurrentTheme(name: string): Promise { - let videoPositionSec: number | null = null; - if (this.host) { - try { - const position = await this.host.getPlaybackPosition(); - if (position.ok && position.hasVideo && Number.isFinite(position.currentTime)) { - videoPositionSec = position.currentTime; - } - } catch { - /* Save without a resume position when no browser client is available. */ - } - } - const theme = await this.store.saveCurrentTheme(name, { videoPositionSec }); - if (theme.type === "video") { - this.activeThemeId = theme.id; - this.lastProgressWriteSec = -1; - } else { - this.activeThemeId = null; - } - return theme; - } - - async listSavedThemes(): Promise { - return this.store.listSavedThemes(); - } - - async deleteSavedTheme(themeId: string): Promise { - const deleted = await this.store.deleteSavedTheme(themeId); - if (deleted && this.activeThemeId === themeId) { - this.activeThemeId = null; - this.lastProgressWriteSec = -1; - } - return deleted; - } - - async useSavedTheme(themeId: string): Promise { - try { - const saved = await this.store.loadSavedTheme(themeId); - const result = await this.apply(saved.input); - if (result.ok) { - this.activeThemeId = saved.input.type === "video" ? saved.themeId : null; - this.lastProgressWriteSec = -1; - } - return result; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : String(error), - rolledBack: false, - }; - } - } - - async setFishMode(enabled: boolean): Promise<{ - ok: boolean; - fish: boolean; - sessions: number; - error?: string; - }> { - if (!this.releaseLock || this.closed || !this.host) { - return { ok: false, fish: this.fishMode, sessions: 0, error: "Session is not started" }; - } - const want = Boolean(enabled); - if (want) { - const manifest = await this.store.readActiveManifest(); - if (!manifest.background) { - this.fishMode = false; - return { - ok: false, - fish: false, - sessions: 0, - error: "No active background. Apply an image or video first.", - }; - } - } - this.fishMode = want; - try { - const result = await this.host.setFishMode(want); - if (result.ok) this.fishMode = result.fish; - else if (!want) this.fishMode = false; - return result; - } catch (error) { - return { - ok: false, - fish: this.fishMode, - sessions: 0, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async setMuted(muted: boolean): Promise<{ - ok: boolean; - muted: boolean; - blocked: boolean; - sessions: number; - error?: string; - }> { - if (!this.releaseLock || this.closed || !this.host) { - return { - ok: false, - muted: this.videoMuted, - blocked: false, - sessions: 0, - error: "Session is not started", - }; - } - this.videoMuted = Boolean(muted); - try { - const result = await this.host.setMuted(this.videoMuted); - if (result.ok) this.videoMuted = result.muted; - return result; - } catch (error) { - return { - ok: false, - muted: this.videoMuted, - blocked: false, - sessions: 0, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async setBackgroundTone(tone: BackgroundTone): Promise<{ - ok: boolean; - tone: BackgroundTone; - sessions: number; - error?: string; - }> { - if (!this.releaseLock || this.closed || !this.host) { - return { ok: false, tone: this.backgroundTone, sessions: 0, error: "Session is not started" }; - } - this.backgroundTone = tone === "light" || tone === "auto" ? tone : "dark"; - try { - const result = await this.host.setBackgroundTone(this.backgroundTone); - if (result.ok) this.backgroundTone = result.tone; - return result; - } catch (error) { - return { - ok: false, - tone: this.backgroundTone, - sessions: 0, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - async stop(): Promise { - if (this.stopTask) return this.stopTask; - this.stopTask = this.stopExclusive(); - return this.stopTask; - } - - private async stopExclusive(): Promise { - this.closed = true; - if (this.watchTimer) clearInterval(this.watchTimer); - this.watchTimer = null; - if (this.handoffTimer) clearInterval(this.handoffTimer); - this.handoffTimer = null; - await Promise.allSettled( - [this.watchTask, ...this.activeOperations].filter( - (value): value is Promise => Boolean(value), - ), - ); - if (this.fishMode && this.host) { - await this.host.setFishMode(false).catch(() => null); - this.fishMode = false; - } - await this.media.close().catch(() => {}); - if (this.releaseLock) await this.releaseLock().catch(() => {}); - this.releaseLock = null; - this.host = null; - } - - private trackOperation(operation: Promise): Promise { - this.activeOperations.add(operation); - void operation.finally(() => this.activeOperations.delete(operation)).catch(() => {}); - return operation; - } - - private bridgePort(): number { - if (this.baseUrl.port) return Number(this.baseUrl.port); - return 80; - } - - private startWatchLoop(): void { - this.watchTimer = setInterval(() => void this.watchOnce(), this.pollMs); - this.watchTimer.unref?.(); - } - - private startHandoffLoop(): void { - this.handoffTimer = setInterval(() => void this.yieldToTrayIfRequested(), 250); - this.handoffTimer.unref?.(); - } - - private async yieldToTrayIfRequested(): Promise { - if (this.closed || this.stopTask) return; - if (!(await trayHandoffRequested(this.dataRoot))) return; - this.onStatus?.("beautiCode 托盘正在接管,正在释放本机会话。"); - await this.stop(); - } - - private async watchOnce(): Promise { - if (this.closed || !this.host || this.watchTask) return; - const task = (async () => { - try { - const [status, manifest] = await Promise.all([ - this.host!.status(), - this.store.readActiveManifest(), - ]); - this.lastWatchError = ""; - const media = manifest.background?.type ?? "clear"; - // Media URLs are process-local. After the tray/session host restarts, - // the DSH page may still report the same generation while pointing at - // a dead loopback server, so force one reapply when local handles are - // absent. - const localMediaMissing = - media === "image" - ? this.media.activeImage == null - : media === "video" - ? this.media.activeImage == null || this.media.activeVideo == null - : false; - const stale = - status.connectedClients > 0 && - (status.current?.generation !== manifest.generation || - status.current.media !== media || - localMediaMissing); - if (stale && !this.userBusy) { - this.onStatus?.("DeepSeek Harness 已连接,正在恢复当前背景。"); - const result = await this.reapply(); - if (!result.ok) throw new Error(result.error); - } - await this.persistBoundThemeProgress(); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - if (err.message !== this.lastWatchError) { - this.lastWatchError = err.message; - this.onError?.(err); - } - } - })(); - this.watchTask = task; - await task.finally(() => { - if (this.watchTask === task) this.watchTask = null; + trustedOrigins: dshTrustedOrigins(this.baseUrl), }); - } - - private async persistBoundThemeProgress(): Promise { - if (!this.activeThemeId || !this.host || this.progressWriteInFlight || this.userBusy) { - return; - } - const now = Date.now(); - if (now - this.lastProgressWriteAt < 2_000) return; - this.progressWriteInFlight = true; - try { - const position = await this.host.getPlaybackPosition(); - if (!position.ok || !position.hasVideo) return; - let seconds = Number(position.currentTime); - if (!Number.isFinite(seconds) || seconds < 0) return; - if (position.duration > 0 && seconds >= position.duration - 0.25) seconds = 0; - if ( - this.lastProgressWriteSec >= 0 && - Math.abs(seconds - this.lastProgressWriteSec) < 0.5 && - !(seconds === 0 && this.lastProgressWriteSec !== 0) - ) { - this.lastProgressWriteAt = now; - return; - } - const result = await this.store.updateSavedThemeVideoPosition( - this.activeThemeId, - seconds, - ); - if (result.ok) { - this.lastProgressWriteAt = now; - this.lastProgressWriteSec = result.positionSec ?? seconds; - } else if (result.error === "Saved theme not found.") { - this.activeThemeId = null; - } - } finally { - this.progressWriteInFlight = false; - } - } -} + this.onError = opts.onError ?? null; + this.onStatus = opts.onStatus ?? null; + } + + get isBusy(): boolean { + return this.userBusy; + } + + get isOpen(): boolean { + return !this.closed && this.releaseLock != null; + } + + get isHostReady(): boolean { + return (this.host?.activeSessionCount ?? 0) > 0; + } + + async start(): Promise<{ port: number | null }> { + if (this.closed) throw new Error("Session already stopped"); + if (this.releaseLock) throw new Error("Session already started"); + await this.store.init(); + this.releaseLock = await acquireDshInjectorLock(this.dataRoot); + try { + const token = await ensureBridgeToken(this.dataRoot); + this.host = new DshHostApplier({ + baseUrl: this.baseUrl.href, + token, + pollMs: Math.min(250, Math.max(50, Math.floor(this.pollMs / 4))), + }); + } catch (error) { + await this.releaseLock().catch(() => {}); + this.releaseLock = null; + throw error; + } + this.startWatchLoop(); + if (this.honorTrayHandoff) { + this.startHandoffLoop(); + void this.yieldToTrayIfRequested(); + } + void this.watchOnce(); + return { port: this.bridgePort() }; + } + + async apply(input: ApplyInput): Promise { + return this.trackOperation(this.applyInternal(input)); + } + + async applyAndSaveTheme( + input: ApplyInput, + name: string, + ): Promise { + const result = await this.trackOperation(this.applyInternal(input, name)); + if (!result.ok) return result; + if (!result.theme) { + return { + ok: false, + error: "Theme apply completed without a saved theme.", + rolledBack: false, + }; + } + return { ...result, theme: result.theme }; + } + + private async applyInternal( + input: ApplyInput, + themeName?: string, + ): Promise { + if (!this.releaseLock || this.closed || !this.host) { + throw new Error("Session is not started"); + } + if (this.userBusy) { + return { + ok: false, + error: "Another background apply is already in progress.", + rolledBack: false, + }; + } + this.userBusy = true; + try { + // The startup/watch probe may already have opened active/background.json + // before userBusy became true. On Windows that read can overlap the + // transaction's atomic active-directory rename and cause EPERM. Drain + // that one read before beginning any apply mutation; later watch ticks + // observe userBusy and do not reapply. + const inFlightWatch = this.watchTask; + if (inFlightWatch) await inFlightWatch; + const tx = new ApplyTransaction({ + store: this.store, + media: this.media, + host: this.host, + includeImageDataUrl: false, + verifyDeadlineMs: this.verifyDeadlineMs, + offline: false, + }); + const saved = { theme: null as SavedThemeInfo | null }; + const result = await tx.run( + input, + themeName + ? { + beforeFinalize: async () => { + saved.theme = await this.store.saveCurrentTheme(themeName); + }, + onRollback: async () => { + if (saved.theme) { + await this.store.deleteSavedTheme(saved.theme.id); + } + }, + } + : undefined, + ); + if (result.ok) { + this.activeThemeId = saved.theme?.type === "video" ? saved.theme.id : null; + this.lastProgressWriteSec = -1; + if (input.type === "clear") { + this.fishMode = false; + } else if (this.fishMode) { + await this.host.setFishMode(true).catch(() => null); + } + if (!this.videoMuted || input.type === "video") { + await this.host.setMuted(this.videoMuted).catch(() => null); + } + if (input.type === "image" && input.effects?.preset === "infernal") { + this.backgroundTone = "dark"; + } else if (input.type === "image" && input.effects?.preset === "internal") { + this.backgroundTone = "light"; + } + await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); + } + return result.ok && saved.theme ? { ...result, theme: saved.theme } : result; + } finally { + this.userBusy = false; + } + } + + async reapply(): Promise { + return this.trackOperation(this.reapplyInternal()); + } + + private async reapplyInternal(): Promise { + if (!this.releaseLock || this.closed || !this.host) { + throw new Error("Session is not started"); + } + if (this.userBusy) { + return { + ok: false, + error: "Another background apply is already in progress.", + rolledBack: false, + }; + } + this.userBusy = true; + let stagedImage = null; + let stagedVideo = null; + try { + const manifest = await this.store.readActiveManifest(); + let resumeAt: number | null = null; + if (manifest.background?.type === "video") { + try { + const position = await this.host.getPlaybackPosition(); + if (position.ok && position.hasVideo && Number.isFinite(position.currentTime)) { + resumeAt = Math.max(0, position.currentTime); + } + } catch { + /* Fall back to the bound saved theme below. */ + } + if (resumeAt == null && this.activeThemeId) { + resumeAt = await this.store.getSavedThemeVideoPosition(this.activeThemeId); + } + } + if (manifest.background) { + const imagePath = resolveBackgroundImagePath( + this.store.paths.activeDir, + manifest.background, + ); + if (!imagePath) throw new Error("Background has no image source."); + stagedImage = await this.media.stage(imagePath, { + validation: + manifest.background.type === "image" && isLocalBackgroundSource(manifest.background) + ? "fast" + : "full", + }); + if (manifest.background.type === "video") { + const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest); + if (!runtimeVideoPath) { + throw new Error("DSH video reapply requires a detached runtime copy."); + } + stagedVideo = await this.media.stage(runtimeVideoPath, { + validation: isLocalBackgroundSource(manifest.background) ? "fast" : "full", + }); + } + } + const staged = { image: stagedImage, video: stagedVideo }; + const payload = await buildHostApplyPayload( + this.store, + manifest, + staged, + "", + undefined, + { includeImageDataUrl: false }, + ); + if (payload.video && resumeAt != null) payload.video.startAt = resumeAt; + await this.host.apply(payload); + const verify = await this.host.verify( + { + generation: manifest.generation, + media: manifest.background?.type ?? "clear", + }, + { deadlineMs: this.verifyDeadlineMs }, + ); + if (verify.status !== "pass") { + await this.media.abort(stagedVideo); + await this.media.abort(stagedImage); + stagedVideo = null; + stagedImage = null; + return { + ok: false, + error: `Live verify did not pass (${verify.status}): ${verify.reason}`, + rolledBack: false, + }; + } + await this.media.commit(staged); + stagedVideo = null; + stagedImage = null; + if (!manifest.background) { + this.fishMode = false; + this.activeThemeId = null; + } else if (this.fishMode) { + await this.host.setFishMode(true).catch(() => null); + } + await this.host.setMuted(this.videoMuted).catch(() => null); + await this.host.setBackgroundTone(this.backgroundTone).catch(() => null); + return { + ok: true, + generation: manifest.generation, + mode: manifest.background?.type ?? "clear", + }; + } catch (error) { + await this.media.abort(stagedVideo); + await this.media.abort(stagedImage); + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + rolledBack: false, + }; + } finally { + this.userBusy = false; + } + } + + async status(): Promise { + const manifest = await this.store.readActiveManifest(); + if (this.host) await this.host.status().catch(() => null); + return { + host: this.descriptor, + port: this.bridgePort(), + sessions: this.host?.activeSessionCount ?? 0, + manifest, + mediaServer: this.media.activeImage?.url ?? this.media.activeVideo?.url ?? null, + fish: this.fishMode, + muted: this.videoMuted, + tone: this.backgroundTone, + }; + } + + async saveCurrentTheme(name: string): Promise { + let videoPositionSec: number | null = null; + if (this.host) { + try { + const position = await this.host.getPlaybackPosition(); + if (position.ok && position.hasVideo && Number.isFinite(position.currentTime)) { + videoPositionSec = position.currentTime; + } + } catch { + /* Save without a resume position when no browser client is available. */ + } + } + const theme = await this.store.saveCurrentTheme(name, { videoPositionSec }); + if (theme.type === "video") { + this.activeThemeId = theme.id; + this.lastProgressWriteSec = -1; + } else { + this.activeThemeId = null; + } + return theme; + } + + async listSavedThemes(): Promise { + return this.store.listSavedThemes(); + } + + async deleteSavedTheme(themeId: string): Promise { + const deleted = await this.store.deleteSavedTheme(themeId); + if (deleted && this.activeThemeId === themeId) { + this.activeThemeId = null; + this.lastProgressWriteSec = -1; + } + return deleted; + } + + async useSavedTheme(themeId: string): Promise { + try { + const saved = await this.store.loadSavedTheme(themeId); + const result = await this.apply(saved.input); + if (result.ok) { + this.activeThemeId = saved.input.type === "video" ? saved.themeId : null; + this.lastProgressWriteSec = -1; + } + return result; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + rolledBack: false, + }; + } + } + + async setFishMode(enabled: boolean): Promise<{ + ok: boolean; + fish: boolean; + sessions: number; + error?: string; + }> { + if (!this.releaseLock || this.closed || !this.host) { + return { ok: false, fish: this.fishMode, sessions: 0, error: "Session is not started" }; + } + const want = Boolean(enabled); + if (want) { + const manifest = await this.store.readActiveManifest(); + if (!manifest.background) { + this.fishMode = false; + return { + ok: false, + fish: false, + sessions: 0, + error: "No active background. Apply an image or video first.", + }; + } + } + this.fishMode = want; + try { + const result = await this.host.setFishMode(want); + if (result.ok) this.fishMode = result.fish; + else if (!want) this.fishMode = false; + return result; + } catch (error) { + return { + ok: false, + fish: this.fishMode, + sessions: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async setMuted(muted: boolean): Promise<{ + ok: boolean; + muted: boolean; + blocked: boolean; + sessions: number; + error?: string; + }> { + if (!this.releaseLock || this.closed || !this.host) { + return { + ok: false, + muted: this.videoMuted, + blocked: false, + sessions: 0, + error: "Session is not started", + }; + } + this.videoMuted = Boolean(muted); + try { + const result = await this.host.setMuted(this.videoMuted); + if (result.ok) this.videoMuted = result.muted; + return result; + } catch (error) { + return { + ok: false, + muted: this.videoMuted, + blocked: false, + sessions: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async setBackgroundTone(tone: BackgroundTone): Promise<{ + ok: boolean; + tone: BackgroundTone; + sessions: number; + error?: string; + }> { + if (!this.releaseLock || this.closed || !this.host) { + return { ok: false, tone: this.backgroundTone, sessions: 0, error: "Session is not started" }; + } + this.backgroundTone = tone === "light" || tone === "auto" ? tone : "dark"; + try { + const result = await this.host.setBackgroundTone(this.backgroundTone); + if (result.ok) this.backgroundTone = result.tone; + return result; + } catch (error) { + return { + ok: false, + tone: this.backgroundTone, + sessions: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async stop(): Promise { + if (this.stopTask) return this.stopTask; + this.stopTask = this.stopExclusive(); + return this.stopTask; + } + + private async stopExclusive(): Promise { + this.closed = true; + if (this.watchTimer) clearInterval(this.watchTimer); + this.watchTimer = null; + if (this.handoffTimer) clearInterval(this.handoffTimer); + this.handoffTimer = null; + await Promise.allSettled( + [this.watchTask, ...this.activeOperations].filter( + (value): value is Promise => Boolean(value), + ), + ); + if (this.fishMode && this.host) { + await this.host.setFishMode(false).catch(() => null); + this.fishMode = false; + } + await this.media.close().catch(() => {}); + if (this.releaseLock) await this.releaseLock().catch(() => {}); + this.releaseLock = null; + this.host = null; + } + + private trackOperation(operation: Promise): Promise { + this.activeOperations.add(operation); + void operation.finally(() => this.activeOperations.delete(operation)).catch(() => {}); + return operation; + } + + private bridgePort(): number { + if (this.baseUrl.port) return Number(this.baseUrl.port); + return 80; + } + + private startWatchLoop(): void { + this.watchTimer = setInterval(() => void this.watchOnce(), this.pollMs); + this.watchTimer.unref?.(); + } + + private startHandoffLoop(): void { + this.handoffTimer = setInterval(() => void this.yieldToTrayIfRequested(), 250); + this.handoffTimer.unref?.(); + } + + private async yieldToTrayIfRequested(): Promise { + if (this.closed || this.stopTask) return; + if (!(await trayHandoffRequested(this.dataRoot))) return; + this.onStatus?.("beautiCode 托盘正在接管,正在释放本机会话。"); + await this.stop(); + } + + private async watchOnce(): Promise { + if (this.closed || !this.host || this.watchTask) return; + const task = (async () => { + try { + const [status, manifest] = await Promise.all([ + this.host!.status(), + this.store.readActiveManifest(), + ]); + this.lastWatchError = ""; + const media = manifest.background?.type ?? "clear"; + // Media URLs are process-local. After the tray/session host restarts, + // the DSH page may still report the same generation while pointing at + // a dead loopback server, so force one reapply when local handles are + // absent. + const localMediaMissing = + media === "image" + ? this.media.activeImage == null + : media === "video" + ? this.media.activeImage == null || this.media.activeVideo == null + : false; + const stale = + status.connectedClients > 0 && + (status.current?.generation !== manifest.generation || + status.current.media !== media || + localMediaMissing); + if (stale && !this.userBusy) { + this.onStatus?.("DeepSeek Harness 已连接,正在恢复当前背景。"); + const result = await this.reapply(); + if (!result.ok) throw new Error(result.error); + } + await this.persistBoundThemeProgress(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (err.message !== this.lastWatchError) { + this.lastWatchError = err.message; + this.onError?.(err); + } + } + })(); + this.watchTask = task; + await task.finally(() => { + if (this.watchTask === task) this.watchTask = null; + }); + } + + private async persistBoundThemeProgress(): Promise { + if (!this.activeThemeId || !this.host || this.progressWriteInFlight || this.userBusy) { + return; + } + const now = Date.now(); + if (now - this.lastProgressWriteAt < 2_000) return; + this.progressWriteInFlight = true; + try { + const position = await this.host.getPlaybackPosition(); + if (!position.ok || !position.hasVideo) return; + let seconds = Number(position.currentTime); + if (!Number.isFinite(seconds) || seconds < 0) return; + if (position.duration > 0 && seconds >= position.duration - 0.25) seconds = 0; + if ( + this.lastProgressWriteSec >= 0 && + Math.abs(seconds - this.lastProgressWriteSec) < 0.5 && + !(seconds === 0 && this.lastProgressWriteSec !== 0) + ) { + this.lastProgressWriteAt = now; + return; + } + const result = await this.store.updateSavedThemeVideoPosition( + this.activeThemeId, + seconds, + ); + if (result.ok) { + this.lastProgressWriteAt = now; + this.lastProgressWriteSec = result.positionSec ?? seconds; + } else if (result.error === "Saved theme not found.") { + this.activeThemeId = null; + } + } finally { + this.progressWriteInFlight = false; + } + } +} diff --git a/packages/adapter-dsh/test/adapter.test.js b/packages/adapter-dsh/test/adapter.test.js index 31b6452..0750bb3 100644 --- a/packages/adapter-dsh/test/adapter.test.js +++ b/packages/adapter-dsh/test/adapter.test.js @@ -1,360 +1,500 @@ -import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import http from "node:http"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; import { DshHostApplier, DshSession, + dshTrustedOrigins, normalizeDshBaseUrl, } from "../dist/index.js"; - -const TOKEN = "a".repeat(64); -const PNG_1X1 = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", -); - -function mp4Fixture(marker = "DSH2") { - const fileTypeBox = Buffer.alloc(24); - fileTypeBox.writeUInt32BE(24, 0); - fileTypeBox.write("ftyp", 4, "ascii"); - fileTypeBox.write("isom", 8, "ascii"); - return Buffer.concat([fileTypeBox, Buffer.from(marker)]); -} - -function json(res, status, body) { - const encoded = JSON.stringify(body); - res.writeHead(status, { "content-type": "application/json" }); - res.end(encoded); -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); -} - -async function mockBridge(expectedToken = TOKEN) { - let current = null; - let received = null; - let connectedClients = 1; - let renderReady = true; - let applyCount = 0; - let modes = { fish: false, muted: true, tone: "dark" }; - let playbackTime = 8.25; - const server = http.createServer(async (req, res) => { - const authorization = String(req.headers.authorization || ""); - const authOk = expectedToken == null - ? /^Bearer [a-f0-9]{64}$/.test(authorization) - : authorization === `Bearer ${expectedToken}`; - if (!authOk) { - json(res, 401, { ok: false, error: "unauthorized" }); - return; - } - if (req.url === "/__beauticode/apply" && req.method === "POST") { - received = await readBody(req); - current = received; - applyCount += 1; - json(res, 200, { ok: true }); - return; - } - if (req.url === "/__beauticode/mode" && req.method === "POST") { - modes = { ...modes, ...(await readBody(req)) }; - json(res, 200, { ok: true, modes }); - return; - } - if (req.url === "/__beauticode/status" && req.method === "GET") { - json(res, 200, { - ok: true, - connectedClients, - current, - readyClients: current && connectedClients > 0 && renderReady ? 1 : 0, - failedClients: 0, - visibleClients: current && current.media !== "clear" && connectedClients > 0 && renderReady ? 1 : 0, - modeReadyClients: connectedClients > 0 ? 1 : 0, - blockedClients: 0, - resolvedTone: modes.tone === "auto" ? "light" : modes.tone, - modes, - playback: current?.media === "video" && connectedClients > 0 - ? { - currentTime: playbackTime, - duration: 30, - hasVideo: true, - muted: modes.muted, - paused: false, - blocked: false, - } - : null, - }); - return; - } - json(res, 404, { ok: false }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - return { - url: `http://127.0.0.1:${address.port}`, - get received() { - return received; - }, - get applyCount() { - return applyCount; - }, - setConnected(value) { - connectedClients = value; - }, - setRenderReady(value) { - renderReady = value; - }, - setPlaybackTime(value) { - playbackTime = value; - }, - close: () => new Promise((resolve) => server.close(resolve)), - }; -} - +import { MediaServerController } from "@beauticode/core"; + +const TOKEN = "a".repeat(64); +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +function mp4Fixture(marker = "DSH2") { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(24, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker)]); +} + +function json(res, status, body) { + const encoded = JSON.stringify(body); + res.writeHead(status, { "content-type": "application/json" }); + res.end(encoded); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); +} + +async function mockBridge(expectedToken = TOKEN) { + let current = null; + let received = null; + let connectedClients = 1; + let renderReady = true; + let renderFailed = false; + let renderError = null; + let applyCount = 0; + let modes = { fish: false, muted: true, tone: "dark" }; + let playbackTime = 8.25; + const server = http.createServer(async (req, res) => { + const authorization = String(req.headers.authorization || ""); + const authOk = expectedToken == null + ? /^Bearer [a-f0-9]{64}$/.test(authorization) + : authorization === `Bearer ${expectedToken}`; + if (!authOk) { + json(res, 401, { ok: false, error: "unauthorized" }); + return; + } + if (req.url === "/__beauticode/apply" && req.method === "POST") { + received = await readBody(req); + current = received; + applyCount += 1; + json(res, 200, { ok: true }); + return; + } + if (req.url === "/__beauticode/mode" && req.method === "POST") { + modes = { ...modes, ...(await readBody(req)) }; + json(res, 200, { ok: true, modes }); + return; + } + if (req.url === "/__beauticode/status" && req.method === "GET") { + json(res, 200, { + ok: true, + connectedClients, + current, + readyClients: current && connectedClients > 0 && renderReady ? 1 : 0, + failedClients: current && connectedClients > 0 && (renderFailed || renderError) ? 1 : 0, + lastRenderError: renderError, + visibleClients: current && current.media !== "clear" && connectedClients > 0 && renderReady ? 1 : 0, + modeReadyClients: connectedClients > 0 ? 1 : 0, + blockedClients: 0, + resolvedTone: modes.tone === "auto" ? "light" : modes.tone, + modes, + playback: current?.media === "video" && connectedClients > 0 + ? { + currentTime: playbackTime, + duration: 30, + hasVideo: true, + muted: modes.muted, + paused: false, + blocked: false, + } + : null, + }); + return; + } + json(res, 404, { ok: false }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}`, + get received() { + return received; + }, + get applyCount() { + return applyCount; + }, + setConnected(value) { + connectedClients = value; + }, + setRenderReady(value) { + renderReady = value; + }, + setRenderError(value) { + renderError = value; + if (value) { + renderFailed = true; + renderReady = false; + } + }, + setRenderFailed(value) { + renderFailed = value; + if (value) renderReady = false; + }, + setPlaybackTime(value) { + playbackTime = value; + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + test("DSH URL only accepts loopback HTTP", () => { assert.equal(normalizeDshBaseUrl("http://127.0.0.1:3080").origin, "http://127.0.0.1:3080"); + assert.deepEqual(dshTrustedOrigins("http://127.0.0.1:3080"), [ + "http://127.0.0.1:3080", + "http://localhost:3080", + "http://[::1]:3080", + ]); + assert.deepEqual(dshTrustedOrigins("http://localhost:3080"), [ + "http://localhost:3080", + "http://127.0.0.1:3080", + "http://[::1]:3080", + ]); assert.throws(() => normalizeDshBaseUrl("https://127.0.0.1:3080"), /loopback HTTP/); - assert.throws(() => normalizeDshBaseUrl("http://192.168.1.10:3080"), /loopback HTTP/); - assert.throws(() => normalizeDshBaseUrl("http://user:pass@localhost:3080"), /loopback HTTP/); - assert.throws(() => normalizeDshBaseUrl("http://localhost:3080/nested"), /loopback HTTP/); -}); - -test("host applier sends a minimal image payload and waits for browser ack", async (t) => { - const bridge = await mockBridge(); - t.after(() => bridge.close()); - const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 10 }); - await host.apply({ - generation: 7, - media: "image", - imageDataUrl: "data:image/png;base64,should-not-cross-bridge", - imageUrl: "http://127.0.0.1:45678/media/image?t=secret", - video: null, - cssText: "should-not-cross-bridge", - }); - assert.deepEqual(bridge.received, { - generation: 7, - media: "image", - imageUrl: "http://127.0.0.1:45678/media/image?t=secret", - videoUrl: null, - startAt: null, - }); - const verified = await host.verify( - { generation: 7, media: "image" }, - { deadlineMs: 100 }, - ); - assert.equal(verified.status, "pass"); - assert.equal(host.activeSessionCount, 1); - await host.apply({ - generation: 71, - media: "image", - imageDataUrl: "data:image/png;base64,should-not-cross-bridge", - imageUrl: "http://127.0.0.1:45678/media/image?t=secret", - video: null, - cssText: "", - atmosphere: { preset: "infernal", rain: true, overlay: true, water: true }, - }); - assert.equal(bridge.received.atmosphere.preset, "infernal"); -}); - -test("host applier sends loopback MP4 only and controls browser modes", async (t) => { - const bridge = await mockBridge(); - t.after(() => bridge.close()); - const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); - await host.apply({ - generation: 8, - media: "video", - imageDataUrl: "data:image/png;base64,private", - imageUrl: "http://127.0.0.1:45678/media/image?t=poster", - video: { - mode: "server", - srcUrl: "http://127.0.0.1:45678/media/video?t=movie", - localPath: "C:\\private\\movie.mp4", - startAt: 4.5, - }, - cssText: "private-css", - }); - assert.deepEqual(bridge.received, { - generation: 8, - media: "video", - imageUrl: "http://127.0.0.1:45678/media/image?t=poster", - videoUrl: "http://127.0.0.1:45678/media/video?t=movie", - startAt: 4.5, - }); - assert.equal((await host.verify({ generation: 8, media: "video" }, { deadlineMs: 50 })).status, "pass"); - assert.deepEqual(await host.getPlaybackPosition(), { - ok: true, - currentTime: 8.25, - duration: 30, - hasVideo: true, - }); - assert.equal((await host.setFishMode(true)).fish, true); - assert.equal((await host.setMuted(false)).muted, false); - assert.equal((await host.setBackgroundTone("light")).tone, "light"); -}); - -test("verify is inconclusive when no DSH browser page is connected", async (t) => { - const bridge = await mockBridge(); - t.after(() => bridge.close()); - bridge.setConnected(0); - const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); - await host.apply({ - generation: 3, - media: "clear", - imageDataUrl: null, - imageUrl: null, - video: null, - cssText: "", - }); - const verified = await host.verify( - { generation: 3, media: "clear" }, - { deadlineMs: 20 }, - ); - assert.equal(verified.status, "inconclusive"); - assert.match(verified.reason, /No DeepSeek Harness browser client/); -}); - -test("DSH session applies MP4, restores its position, and controls modes", async (t) => { - const bridge = await mockBridge(null); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-test-")); - const image = path.join(root, "input.png"); - const video = path.join(root, "input.mp4"); - const dataRoot = path.join(root, "data"); - await fs.writeFile(image, PNG_1X1); - await fs.writeFile(video, mp4Fixture()); - const session = new DshSession({ - baseUrl: bridge.url, - dataRoot, - verifyDeadlineMs: 200, - pollMs: 60_000, - }); - t.after(async () => { - await session.stop(); - await bridge.close(); - await fs.rm(root, { recursive: true, force: true }); - }); - await session.start(); - const applied = await session.apply({ type: "image", imagePath: image }); - assert.equal(applied.ok, true); - assert.equal((await session.status()).manifest.background?.type, "image"); - - const videoApplied = await session.apply({ type: "video", imagePath: image, videoPath: video }); - assert.equal(videoApplied.ok, true); - assert.equal((await session.status()).manifest.background?.type, "video"); - assert.equal((await session.setFishMode(true)).ok, true); - assert.equal((await session.setMuted(false)).ok, true); - assert.equal((await session.setBackgroundTone("light")).ok, true); - const status = await session.status(); - assert.equal(status.fish, true); - assert.equal(status.muted, false); - assert.equal(status.tone, "light"); - - bridge.setPlaybackTime(8.25); - const saved = await session.saveCurrentTheme("视频主题"); - assert.equal(saved.videoPositionSec, 8.25); - bridge.setPlaybackTime(11.5); - const restored = await session.useSavedTheme(saved.id); - assert.equal(restored.ok, true); - assert.equal(bridge.received.startAt, 8.25); - bridge.setPlaybackTime(13.5); - assert.equal((await session.reapply()).ok, true); - assert.equal(bridge.received.startAt, 13.5); -}); - -test("DSH watch loop does not republish a matching generation while render ack is pending", async (t) => { - const bridge = await mockBridge(null); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-watch-")); - const image = path.join(root, "input.png"); - await fs.writeFile(image, PNG_1X1); - const session = new DshSession({ - baseUrl: bridge.url, - dataRoot: path.join(root, "data"), - verifyDeadlineMs: 100, - pollMs: 20, - }); - t.after(async () => { - await session.stop(); - await bridge.close(); - await fs.rm(root, { recursive: true, force: true }); - }); - await session.start(); - assert.equal((await session.apply({ type: "image", imagePath: image })).ok, true); - const applyCount = bridge.applyCount; - - bridge.setRenderReady(false); - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.equal(bridge.applyCount, applyCount); -}); - -test("DSH session yields the injector lock when the tray claims it", async (t) => { - const bridge = await mockBridge(null); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-yield-")); - const dataRoot = path.join(root, "data"); - const session = new DshSession({ - baseUrl: bridge.url, - dataRoot, - verifyDeadlineMs: 50, - pollMs: 60_000, - }); - t.after(async () => { - await session.stop(); - await bridge.close(); - await fs.rm(root, { recursive: true, force: true }); - }); - await session.start(); - assert.equal(session.isOpen, true); - await fs.writeFile( - path.join(dataRoot, "tray-claim.json"), - `${JSON.stringify({ - schema: "beauticode.tray-claim/v1", - pid: process.pid, - startedAt: new Date().toISOString(), - })}\n`, - ); - const deadline = Date.now() + 3_000; + assert.throws(() => normalizeDshBaseUrl("http://192.168.1.10:3080"), /loopback HTTP/); + assert.throws(() => normalizeDshBaseUrl("http://user:pass@localhost:3080"), /loopback HTTP/); + assert.throws(() => normalizeDshBaseUrl("http://localhost:3080/nested"), /loopback HTTP/); +}); + +test("host applier sends a minimal image payload and waits for browser ack", async (t) => { + const bridge = await mockBridge(); + t.after(() => bridge.close()); + const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 10 }); + await host.apply({ + generation: 7, + media: "image", + imageDataUrl: "data:image/png;base64,should-not-cross-bridge", + imageUrl: "http://127.0.0.1:45678/media/image?t=secret", + video: null, + cssText: "should-not-cross-bridge", + }); + assert.deepEqual(bridge.received, { + generation: 7, + media: "image", + imageUrl: "http://127.0.0.1:45678/media/image?t=secret", + videoUrl: null, + startAt: null, + }); + const verified = await host.verify( + { generation: 7, media: "image" }, + { deadlineMs: 100 }, + ); + assert.equal(verified.status, "pass"); + assert.equal(host.activeSessionCount, 1); + await host.apply({ + generation: 71, + media: "image", + imageDataUrl: "data:image/png;base64,should-not-cross-bridge", + imageUrl: "http://127.0.0.1:45678/media/image?t=secret", + video: null, + cssText: "", + atmosphere: { preset: "infernal", rain: true, overlay: true, water: true }, + }); + assert.equal(bridge.received.atmosphere.preset, "infernal"); +}); + +test("host applier sends loopback MP4 only and controls browser modes", async (t) => { + const bridge = await mockBridge(); + t.after(() => bridge.close()); + const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); + await host.apply({ + generation: 8, + media: "video", + imageDataUrl: "data:image/png;base64,private", + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + video: { + mode: "server", + srcUrl: "http://127.0.0.1:45678/media/video?t=movie", + localPath: "C:\\private\\movie.mp4", + startAt: 4.5, + }, + cssText: "private-css", + }); + assert.deepEqual(bridge.received, { + generation: 8, + media: "video", + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + videoUrl: "http://127.0.0.1:45678/media/video?t=movie", + startAt: 4.5, + }); + assert.equal((await host.verify({ generation: 8, media: "video" }, { deadlineMs: 50 })).status, "pass"); + assert.deepEqual(await host.getPlaybackPosition(), { + ok: true, + currentTime: 8.25, + duration: 30, + hasVideo: true, + }); + assert.equal((await host.setFishMode(true)).fish, true); + assert.equal((await host.setMuted(false)).muted, false); + assert.equal((await host.setBackgroundTone("light")).tone, "light"); +}); + +test("verify is inconclusive when no DSH browser page is connected", async (t) => { + const bridge = await mockBridge(); + t.after(() => bridge.close()); + bridge.setConnected(0); + const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); + await host.apply({ + generation: 3, + media: "clear", + imageDataUrl: null, + imageUrl: null, + video: null, + cssText: "", + }); + const verified = await host.verify( + { generation: 3, media: "clear" }, + { deadlineMs: 20 }, + ); + assert.equal(verified.status, "inconclusive"); + assert.match(verified.reason, /No DeepSeek Harness browser client/); +}); + +test("verify preserves the renderer media failure details", async (t) => { + const bridge = await mockBridge(); + t.after(() => bridge.close()); + bridge.setRenderError( + "视频解码器报告失败;mediaError=MEDIA_ERR_DECODE;readyState=1;networkState=2;paused=true", + ); + const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); + await host.apply({ + generation: 9, + media: "video", + imageDataUrl: null, + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + video: { + mode: "server", + srcUrl: "http://127.0.0.1:45678/media/video?t=movie", + localPath: "C:\\movie.mp4", + }, + cssText: "", + }); + const verified = await host.verify( + { generation: 9, media: "video" }, + { deadlineMs: 50 }, + ); + assert.equal(verified.status, "fail"); + assert.match(verified.reason, /MEDIA_ERR_DECODE/); + assert.match(verified.reason, /readyState=1/); +}); + +test("verify does not treat a generic transient heartbeat as terminal failure", async (t) => { + const bridge = await mockBridge(); + t.after(() => bridge.close()); + bridge.setRenderReady(false); + bridge.setRenderFailed(true); + const host = new DshHostApplier({ baseUrl: bridge.url, token: TOKEN, pollMs: 5 }); + await host.apply({ + generation: 10, + media: "video", + imageDataUrl: null, + imageUrl: "http://127.0.0.1:45678/media/image?t=poster", + video: { + mode: "server", + srcUrl: "http://127.0.0.1:45678/media/video?t=movie", + localPath: "C:\\movie.mp4", + }, + cssText: "", + }); + const verified = await host.verify( + { generation: 10, media: "video" }, + { deadlineMs: 20 }, + ); + assert.equal(verified.status, "inconclusive"); +}); + +test("DSH session applies MP4, restores its position, and controls modes", async (t) => { + const bridge = await mockBridge(null); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-test-")); + const image = path.join(root, "input.png"); + const video = path.join(root, "input.mp4"); + const dataRoot = path.join(root, "data"); + await fs.writeFile(image, PNG_1X1); + await fs.writeFile(video, mp4Fixture()); + const session = new DshSession({ + baseUrl: bridge.url, + dataRoot, + verifyDeadlineMs: 200, + pollMs: 60_000, + }); + t.after(async () => { + await session.stop(); + await bridge.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await session.start(); + const applied = await session.apply({ type: "image", imagePath: image }); + assert.equal(applied.ok, true); + assert.equal((await session.status()).manifest.background?.type, "image"); + + const videoApplied = await session.applyAndSaveTheme( + { type: "video", imagePath: image, videoPath: video, source: "local" }, + "本地视频主题", + ); + assert.equal(videoApplied.ok, true); + assert.equal(videoApplied.theme?.name, "本地视频主题"); + assert.equal((await session.status()).manifest.background?.type, "video"); + assert.equal((await session.status()).manifest.background?.source?.path, video); + assert.deepEqual((await fs.readdir(path.join(dataRoot, "active"))).sort(), [ + "background.json", + "poster.png", + ]); + assert.equal( + (await session.listSavedThemes()).find((theme) => !theme.bundled)?.id, + videoApplied.theme?.id, + ); + assert.equal((await session.setFishMode(true)).ok, true); + assert.equal((await session.setMuted(false)).ok, true); + assert.equal((await session.setBackgroundTone("light")).ok, true); + const status = await session.status(); + assert.equal(status.fish, true); + assert.equal(status.muted, false); + assert.equal(status.tone, "light"); + + bridge.setPlaybackTime(8.25); + const saved = await session.saveCurrentTheme("视频主题"); + assert.equal(saved.videoPositionSec, 8.25); + bridge.setPlaybackTime(11.5); + const restored = await session.useSavedTheme(saved.id); + assert.equal(restored.ok, true); + assert.equal(bridge.received.startAt, 8.25); + bridge.setPlaybackTime(13.5); + assert.equal((await session.reapply()).ok, true); + assert.equal(bridge.received.startAt, 13.5); +}); + +test("DSH watch loop does not republish a matching generation while render ack is pending", async (t) => { + const bridge = await mockBridge(null); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-watch-")); + const image = path.join(root, "input.png"); + await fs.writeFile(image, PNG_1X1); + const session = new DshSession({ + baseUrl: bridge.url, + dataRoot: path.join(root, "data"), + verifyDeadlineMs: 100, + pollMs: 20, + }); + t.after(async () => { + await session.stop(); + await bridge.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await session.start(); + const firstApply = await session.apply({ type: "image", imagePath: image }); + assert.equal(firstApply.ok, true, JSON.stringify(firstApply)); + const applyCount = bridge.applyCount; + + bridge.setRenderReady(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.equal(bridge.applyCount, applyCount); +}); + +test("DSH session yields the injector lock when the tray claims it", async (t) => { + const bridge = await mockBridge(null); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-yield-")); + const dataRoot = path.join(root, "data"); + const session = new DshSession({ + baseUrl: bridge.url, + dataRoot, + verifyDeadlineMs: 50, + pollMs: 60_000, + }); + t.after(async () => { + await session.stop(); + await bridge.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + await session.start(); + assert.equal(session.isOpen, true); + await fs.writeFile( + path.join(dataRoot, "tray-claim.json"), + `${JSON.stringify({ + schema: "beauticode.tray-claim/v1", + pid: process.pid, + startedAt: new Date().toISOString(), + })}\n`, + ); + const deadline = Date.now() + 3_000; while (session.isOpen && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)); } assert.equal(session.isOpen, false); - await assert.rejects(() => fs.readFile(path.join(dataRoot, "injector.lock")), { + const lockPath = path.join(dataRoot, "injector.lock"); + const lockDeadline = Date.now() + 3_000; + while (Date.now() < lockDeadline) { + try { + await fs.access(lockPath); + await new Promise((resolve) => setTimeout(resolve, 50)); + } catch (error) { + if (error?.code === "ENOENT") break; + throw error; + } + } + await assert.rejects(() => fs.readFile(lockPath), { code: "ENOENT", }); }); -test("DSH session rolls disk state back when the bridge disappears", async (t) => { - const bridge = await mockBridge(null); - const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-rollback-")); - const firstImage = path.join(root, "first.png"); - const secondImage = path.join(root, "second.png"); - await fs.writeFile(firstImage, PNG_1X1); - await fs.writeFile(secondImage, Buffer.concat([PNG_1X1, Buffer.from("different")])); - const session = new DshSession({ - baseUrl: bridge.url, - dataRoot: path.join(root, "data"), - verifyDeadlineMs: 50, - pollMs: 60_000, +test("DSH video media permits localhost and IPv6 loopback origins on the same port", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-dsh-origin-")); + const videoPath = path.join(root, "background.mp4"); + await fs.writeFile(videoPath, mp4Fixture("ORIGIN")); + const media = new MediaServerController({ + trustedOrigins: dshTrustedOrigins("http://127.0.0.1:3080"), }); t.after(async () => { - await session.stop(); + await media.close(); await fs.rm(root, { recursive: true, force: true }); }); - await session.start(); - assert.equal((await session.apply({ type: "image", imagePath: firstImage })).ok, true); - const before = (await session.status()).manifest; - await bridge.close(); + const staged = await media.stage(videoPath); + assert.ok(staged); - const failed = await session.apply({ type: "image", imagePath: secondImage }); - assert.equal(failed.ok, false); - assert.equal(failed.rolledBack, true); - const after = (await session.status()).manifest; - assert.deepEqual(after.background, before.background); - assert.ok(after.generation > before.generation); - assert.deepEqual( - await fs.readFile(path.join(root, "data", "active", after.background.image)), - PNG_1X1, - ); + for (const origin of ["http://localhost:3080", "http://[::1]:3080"]) { + const response = await fetch(staged.srcUrl, { + headers: { Origin: origin, Range: "bytes=0-1" }, + }); + assert.equal(response.status, 206); + assert.equal(response.headers.get("access-control-allow-origin"), origin); + assert.equal((await response.arrayBuffer()).byteLength, 2); + } + + const denied = await fetch(staged.srcUrl, { + headers: { Origin: "http://192.168.1.10:3080", Range: "bytes=0-1" }, + }); + assert.equal(denied.status, 403); }); + +test("DSH session rolls disk state back when the bridge disappears", async (t) => { + const bridge = await mockBridge(null); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-dsh-rollback-")); + const firstImage = path.join(root, "first.png"); + const secondImage = path.join(root, "second.png"); + await fs.writeFile(firstImage, PNG_1X1); + await fs.writeFile(secondImage, Buffer.concat([PNG_1X1, Buffer.from("different")])); + const session = new DshSession({ + baseUrl: bridge.url, + dataRoot: path.join(root, "data"), + verifyDeadlineMs: 50, + pollMs: 60_000, + }); + t.after(async () => { + await session.stop(); + await fs.rm(root, { recursive: true, force: true }); + }); + await session.start(); + assert.equal((await session.apply({ type: "image", imagePath: firstImage })).ok, true); + const before = (await session.status()).manifest; + await bridge.close(); + + const failed = await session.applyAndSaveTheme( + { type: "image", imagePath: secondImage, source: "local" }, + "不应保留", + ); + assert.equal(failed.ok, false); + assert.equal(failed.rolledBack, true); + const after = (await session.status()).manifest; + assert.deepEqual(after.background, before.background); + assert.deepEqual( + (await session.listSavedThemes()).filter((theme) => !theme.bundled), + [], + ); + assert.ok(after.generation > before.generation); + assert.deepEqual( + await fs.readFile(path.join(root, "data", "active", after.background.image)), + PNG_1X1, + ); +}); diff --git a/packages/adapter-dsh/test/launcher-scripts.test.js b/packages/adapter-dsh/test/launcher-scripts.test.js index 55128db..3274d51 100644 --- a/packages/adapter-dsh/test/launcher-scripts.test.js +++ b/packages/adapter-dsh/test/launcher-scripts.test.js @@ -1,284 +1,299 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, "../../.."); - -const scripts = [ - "scripts/start-beauticode.ps1", - "scripts/start-beauticode-engine.ps1", - "scripts/codex-launch.ps1", - "scripts/install-dsh-plugin.ps1", - "apps/tray/start-tray.ps1", -]; - -test("host scripts parse", () => { - for (const relative of scripts) { - const full = path.join(repoRoot, relative); - const command = [ - "$errors = $null", - `$null = [System.Management.Automation.Language.Parser]::ParseFile('${full.replace(/'/g, "''")}', [ref]$null, [ref]$errors)`, - "if ($errors -and $errors.Count) { $errors | ForEach-Object { $_.ToString() }; exit 1 }", - ].join("; "); - const result = spawnSync( - "powershell.exe", - ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal( - result.status, - 0, - `${relative} failed to parse:\n${result.stdout}\n${result.stderr}`, - ); - } -}); - -test("DSH runtime launcher and installer are gone", () => { - assert.equal( - fs.existsSync(path.join(repoRoot, "scripts/start-beauticode-dsh.ps1")), - false, - ); - assert.equal( - fs.existsSync(path.join(repoRoot, "scripts/install-dsh-runtime.ps1")), - false, - ); - assert.equal( - fs.existsSync( - path.join(repoRoot, "integrations/deepseek-harness/compatibility.json"), - ), - false, - ); -}); - -test("tray lifecycle owns leftover session-host and second-click show-panel", () => { - const tray = fs.readFileSync( - path.join(repoRoot, "apps/tray/start-tray.ps1"), - "utf8", - ); - const host = fs.readFileSync( - path.join(repoRoot, "apps/tray/session-host.mjs"), - "utf8", - ); - assert.match(tray, /BeautiCodeJob/); - assert.match(tray, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE/); - assert.match(tray, /Stop-BcSessionHost/); - assert.match(tray, /Write-BcTrayClaim/); - assert.match(tray, /Use-BcAdoptedControl/); - assert.match(tray, /existing tray signaled to show panel/); - assert.match(tray, /--parent-pid/); - assert.doesNotMatch(tray, /\$pid\s*=/); - assert.match(host, /--parent-pid/); - assert.match(host, /writeSessionHostFile/); -}); - -test("README documents installer auto-wiring, npx, and custom install paths", () => { - const readme = fs.readFileSync(path.join(repoRoot, "README.md"), "utf8"); - assert.match(readme, /不需要(?:安装)? ?pnpm/); - assert.match(readme, /npx @deepseek-ai\/dsh web/); - assert.match(readme, /npx @deepseek-ai\/dsh plugin/); - assert.match(readme, /集成说明\.txt/); - assert.match(readme, /(?:一般|也)不需要再执行 `dsh plugin add`/); -}); - -test("install-dsh-plugin writes an integration note for the actual install root", () => { - const script = path.join(repoRoot, "scripts/install-dsh-plugin.ps1"); - const pluginRoot = path.join(repoRoot, "integrations/deepseek-harness"); - const home = fs.mkdtempSync(path.join(os.tmpdir(), "bc-dsh-home-")); - const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bc-install-")); - try { - const add = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-PluginRoot", - pluginRoot, - "-DshHome", - home, - "-InstallRoot", - installRoot, - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(add.status, 0, add.stderr || add.stdout); - const note = fs.readFileSync(path.join(installRoot, "集成说明.txt"), "utf8"); - assert.match(note, /不需要安装 pnpm/); - assert.match(note, /npx @deepseek-ai\/dsh web/); - assert.match(note, new RegExp(installRoot.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"))); - const remove = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-PluginRoot", - pluginRoot, - "-DshHome", - home, - "-InstallRoot", - installRoot, - "-Remove", - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(remove.status, 0, remove.stderr || remove.stdout); - assert.equal(fs.existsSync(path.join(installRoot, "集成说明.txt")), false); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - fs.rmSync(installRoot, { recursive: true, force: true }); - } -}); - -test("picker and tray never spawn a DSH process", () => { - const picker = fs.readFileSync( - path.join(repoRoot, "scripts/start-beauticode.ps1"), - "utf8", - ); - const tray = fs.readFileSync( - path.join(repoRoot, "apps/tray/start-tray.ps1"), - "utf8", - ); - const installer = fs.readFileSync( - path.join(repoRoot, "scripts/build-windows-installer.ps1"), - "utf8", - ); - for (const [name, source] of [ - ["start-beauticode.ps1", picker], - ["start-tray.ps1", tray], - ["build-windows-installer.ps1", installer], - ]) { - assert.doesNotMatch(source, /start-beauticode-dsh/, `${name} must not launch DSH`); - assert.doesNotMatch(source, /install-dsh-runtime/, `${name} must not install DSH`); - } - assert.match(installer, /agent\.mjs/); - assert.match(installer, /control-client\.mjs/); - assert.match(installer, /host-apply\.mjs/); - assert.match(installer, /integration-note\.zh\.txt/); -}); - -test("picker DryRun routes DSH to the tray and Codex to its launcher", () => { - const script = path.join(repoRoot, "scripts/start-beauticode.ps1"); - for (const [host, needle] of [ - ["dsh", "start-tray.ps1"], - ["codex", "start-beauticode-engine.ps1"], - ]) { - const result = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-DryRun", - "-TargetHost", - host, - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, new RegExp(needle.replace(/[.]/g, "\\."))); - } -}); - -test("install-dsh-plugin wires a missing DSH home and can uninstall", () => { - const script = path.join(repoRoot, "scripts/install-dsh-plugin.ps1"); - const pluginRoot = path.join(repoRoot, "integrations/deepseek-harness"); - const home = fs.mkdtempSync(path.join(os.tmpdir(), "bc-dsh-home-")); - try { - const add = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-PluginRoot", - pluginRoot, - "-DshHome", - home, - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(add.status, 0, add.stderr || add.stdout); - const homePatch = fs.readFileSync(path.join(home, "cordis.patch.yml"), "utf8"); - assert.match(homePatch, /id: beauticode-bridge/); - assert.match(homePatch, /file:\/\//); - - const web = path.join(home, "profiles", "web"); - fs.mkdirSync(web, { recursive: true }); - fs.writeFileSync( - path.join(web, "package.json"), - JSON.stringify({ - name: "dsh-profile-web", - private: true, - dependencies: {}, - dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"] } }, - }), - "utf8", - ); - fs.writeFileSync(path.join(web, "cordis.patch.yml"), "[]\n", "utf8"); - const migrate = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-PluginRoot", - pluginRoot, - "-DshHome", - home, - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(migrate.status, 0, migrate.stderr || migrate.stdout); - const webPatch = fs.readFileSync(path.join(web, "cordis.patch.yml"), "utf8"); - assert.match(webPatch, /@beauticode\/dsh-plugin/); - assert.equal(fs.existsSync(path.join(home, "cordis.patch.yml")), false); - const pkgBytes = fs.readFileSync(path.join(web, "package.json")); - assert.notEqual(pkgBytes[0], 0xef, "profile package.json must not have a UTF-8 BOM"); - JSON.parse(pkgBytes.toString("utf8")); - assert.ok( - fs.existsSync( - path.join(web, "node_modules", "@beauticode", "dsh-plugin", "index.mjs"), - ), - ); - - const remove = spawnSync( - "powershell.exe", - [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - script, - "-PluginRoot", - pluginRoot, - "-DshHome", - home, - "-Remove", - ], - { encoding: "utf8", windowsHide: true }, - ); - assert.equal(remove.status, 0, remove.stderr || remove.stdout); - assert.equal( - fs.existsSync(path.join(web, "node_modules", "@beauticode", "dsh-plugin")), - false, - ); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } -}); +const powerShellExecutable = process.platform === "win32" ? "powershell.exe" : "pwsh"; +const powerShellAvailable = + spawnSync(powerShellExecutable, ["-NoProfile", "-Command", "exit 0"], { + encoding: "utf8", + windowsHide: true, + }).status === 0; +const requiresPowerShell = { + skip: powerShellAvailable ? false : "PowerShell is not available on this runner.", +}; +const requiresWindowsPowerShell = { + skip: + process.platform === "win32" && powerShellAvailable + ? false + : "This installer integration requires Windows PowerShell and junctions.", +}; + +const scripts = [ + "scripts/start-beauticode.ps1", + "scripts/start-beauticode-engine.ps1", + "scripts/codex-launch.ps1", + "scripts/install-dsh-plugin.ps1", + "apps/tray/start-tray.ps1", +]; + +test("host scripts parse", requiresPowerShell, () => { + for (const relative of scripts) { + const full = path.join(repoRoot, relative); + const command = [ + "$errors = $null", + `$null = [System.Management.Automation.Language.Parser]::ParseFile('${full.replace(/'/g, "''")}', [ref]$null, [ref]$errors)`, + "if ($errors -and $errors.Count) { $errors | ForEach-Object { $_.ToString() }; exit 1 }", + ].join("; "); + const result = spawnSync( + powerShellExecutable, + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal( + result.status, + 0, + `${relative} failed to parse:\n${result.stdout}\n${result.stderr}`, + ); + } +}); + +test("DSH runtime launcher and installer are gone", () => { + assert.equal( + fs.existsSync(path.join(repoRoot, "scripts/start-beauticode-dsh.ps1")), + false, + ); + assert.equal( + fs.existsSync(path.join(repoRoot, "scripts/install-dsh-runtime.ps1")), + false, + ); + assert.equal( + fs.existsSync( + path.join(repoRoot, "integrations/deepseek-harness/compatibility.json"), + ), + false, + ); +}); + +test("tray lifecycle owns leftover session-host and second-click show-panel", () => { + const tray = fs.readFileSync( + path.join(repoRoot, "apps/tray/start-tray.ps1"), + "utf8", + ); + const host = fs.readFileSync( + path.join(repoRoot, "apps/tray/session-host.mjs"), + "utf8", + ); + assert.match(tray, /BeautiCodeJob/); + assert.match(tray, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE/); + assert.match(tray, /Stop-BcSessionHost/); + assert.match(tray, /Write-BcTrayClaim/); + assert.match(tray, /Use-BcAdoptedControl/); + assert.match(tray, /existing tray signaled to show panel/); + assert.match(tray, /--parent-pid/); + assert.doesNotMatch(tray, /\$pid\s*=/); + assert.match(host, /--parent-pid/); + assert.match(host, /writeSessionHostFile/); +}); + +test("README documents installer auto-wiring, npx, and custom install paths", () => { + const readme = fs.readFileSync(path.join(repoRoot, "README.md"), "utf8"); + assert.match(readme, /不需要(?:安装)? ?pnpm/); + assert.match(readme, /npx @deepseek-ai\/dsh web/); + assert.match(readme, /npx @deepseek-ai\/dsh plugin/); + assert.match(readme, /集成说明\.txt/); + assert.match(readme, /(?:一般|也)不需要再执行 `dsh plugin add`/); +}); + +test("install-dsh-plugin writes an integration note for the actual install root", requiresPowerShell, () => { + const script = path.join(repoRoot, "scripts/install-dsh-plugin.ps1"); + const pluginRoot = path.join(repoRoot, "integrations/deepseek-harness"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bc-dsh-home-")); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bc-install-")); + try { + const add = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-PluginRoot", + pluginRoot, + "-DshHome", + home, + "-InstallRoot", + installRoot, + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(add.status, 0, add.stderr || add.stdout); + const note = fs.readFileSync(path.join(installRoot, "集成说明.txt"), "utf8"); + assert.match(note, /不需要安装 pnpm/); + assert.match(note, /npx @deepseek-ai\/dsh web/); + assert.match(note, new RegExp(installRoot.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"))); + const remove = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-PluginRoot", + pluginRoot, + "-DshHome", + home, + "-InstallRoot", + installRoot, + "-Remove", + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(remove.status, 0, remove.stderr || remove.stdout); + assert.equal(fs.existsSync(path.join(installRoot, "集成说明.txt")), false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(installRoot, { recursive: true, force: true }); + } +}); + +test("picker and tray never spawn a DSH process", () => { + const picker = fs.readFileSync( + path.join(repoRoot, "scripts/start-beauticode.ps1"), + "utf8", + ); + const tray = fs.readFileSync( + path.join(repoRoot, "apps/tray/start-tray.ps1"), + "utf8", + ); + const installer = fs.readFileSync( + path.join(repoRoot, "scripts/build-windows-installer.ps1"), + "utf8", + ); + for (const [name, source] of [ + ["start-beauticode.ps1", picker], + ["start-tray.ps1", tray], + ["build-windows-installer.ps1", installer], + ]) { + assert.doesNotMatch(source, /start-beauticode-dsh/, `${name} must not launch DSH`); + assert.doesNotMatch(source, /install-dsh-runtime/, `${name} must not install DSH`); + } + assert.match(installer, /agent\.mjs/); + assert.match(installer, /control-client\.mjs/); + assert.match(installer, /host-apply\.mjs/); + assert.match(installer, /integration-note\.zh\.txt/); +}); + +test("picker DryRun routes DSH to the tray and Codex to its launcher", requiresPowerShell, () => { + const script = path.join(repoRoot, "scripts/start-beauticode.ps1"); + for (const [host, needle] of [ + ["dsh", "start-tray.ps1"], + ["codex", "start-beauticode-engine.ps1"], + ]) { + const result = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-DryRun", + "-TargetHost", + host, + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(needle.replace(/[.]/g, "\\."))); + } +}); + +test("install-dsh-plugin wires a missing DSH home and can uninstall", requiresWindowsPowerShell, () => { + const script = path.join(repoRoot, "scripts/install-dsh-plugin.ps1"); + const pluginRoot = path.join(repoRoot, "integrations/deepseek-harness"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bc-dsh-home-")); + try { + const add = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-PluginRoot", + pluginRoot, + "-DshHome", + home, + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(add.status, 0, add.stderr || add.stdout); + const homePatch = fs.readFileSync(path.join(home, "cordis.patch.yml"), "utf8"); + assert.match(homePatch, /id: beauticode-bridge/); + assert.match(homePatch, /file:\/\//); + + const web = path.join(home, "profiles", "web"); + fs.mkdirSync(web, { recursive: true }); + fs.writeFileSync( + path.join(web, "package.json"), + JSON.stringify({ + name: "dsh-profile-web", + private: true, + dependencies: {}, + dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"] } }, + }), + "utf8", + ); + fs.writeFileSync(path.join(web, "cordis.patch.yml"), "[]\n", "utf8"); + const migrate = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-PluginRoot", + pluginRoot, + "-DshHome", + home, + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(migrate.status, 0, migrate.stderr || migrate.stdout); + const webPatch = fs.readFileSync(path.join(web, "cordis.patch.yml"), "utf8"); + assert.match(webPatch, /@beauticode\/dsh-plugin/); + assert.equal(fs.existsSync(path.join(home, "cordis.patch.yml")), false); + const pkgBytes = fs.readFileSync(path.join(web, "package.json")); + assert.notEqual(pkgBytes[0], 0xef, "profile package.json must not have a UTF-8 BOM"); + JSON.parse(pkgBytes.toString("utf8")); + assert.ok( + fs.existsSync( + path.join(web, "node_modules", "@beauticode", "dsh-plugin", "index.mjs"), + ), + ); + + const remove = spawnSync( + powerShellExecutable, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-PluginRoot", + pluginRoot, + "-DshHome", + home, + "-Remove", + ], + { encoding: "utf8", windowsHide: true }, + ); + assert.equal(remove.status, 0, remove.stderr || remove.stdout); + assert.equal( + fs.existsSync(path.join(web, "node_modules", "@beauticode", "dsh-plugin")), + false, + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/apply-transaction.ts b/packages/core/src/apply-transaction.ts index b41eabd..f66e6b4 100644 --- a/packages/core/src/apply-transaction.ts +++ b/packages/core/src/apply-transaction.ts @@ -1,371 +1,456 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { - BackgroundStore, - type BackgroundSnapshot, -} from "./background-store.js"; -import { MAX_INLINE_DATA_URL_BYTES } from "./constants.js"; -import { - MediaServerController, - type MediaAssetHandle, -} from "./media-server.js"; -import { detectImageMime } from "./media-validation.js"; -import type { - ApplyInput, - ApplyResult, - BackgroundManifest, - HostApplier, - HostApplyPayload, -} from "./types.js"; - -export interface ApplyTransactionOptions { - store: BackgroundStore; - media: MediaServerController; - host?: HostApplier | null; - /** Background-only CSS text injected with every apply. */ - cssText?: string; - verifyDeadlineMs?: number; - /** When true, skip host apply/verify (unit tests / offline stage). */ - offline?: boolean; -} - -/** - * Minimal baseline only — live Codex path injects packages/adapter-codex - * renderer/background.css (full-window main-surface transparency). - * Keep this minimal so offline unit tests still paint a stage. - */ -const DEFAULT_CSS = `/* beautiCode background stage baseline */ -#beauticode-bg-stage{ - position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:transparent!important; -} -#beauticode-bg-stage::before{ - content:"";position:absolute;inset:0;z-index:3;pointer-events:none;background:transparent; -} -#beauticode-bg-stage img,#beauticode-bg-stage video{ - position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none; -} -#beauticode-bg-stage img{z-index:1;} -#beauticode-bg-stage video{z-index:2;opacity:0;} -html[data-bc-active="true"][data-bc-media="video"][data-bc-video-ready="true"] #beauticode-bg-stage video{opacity:1;} -html[data-bc-active="true"][data-bc-media="video"][data-bc-video-ready="true"] #beauticode-bg-stage img{display:none!important;} -html[data-bc-active="true"][data-bc-media="video-pending"] #beauticode-bg-stage img{opacity:1;display:block;} -`; - -export interface StagedMediaPair { - image: MediaAssetHandle | null; - video: MediaAssetHandle | null; -} - -/** - * Orchestrates snapshot → disk commit → media stage → host apply → verify → - * finalize/rollback. Disk success is never treated as user-visible success when - * a host applier is configured. - * - * Codex Desktop CSP blocks connect-src/img-src/media-src to http://127.0.0.1, - * so live inject embeds media as data: URLs (CSP allows data: and blob:). - * Loopback media hub remains staged for diagnostics / non-Codex hosts. - */ -export class ApplyTransaction { - readonly store: BackgroundStore; - readonly media: MediaServerController; - readonly host: HostApplier | null; - readonly cssText: string; - readonly verifyDeadlineMs: number; - readonly offline: boolean; - #busy = false; - - constructor(opts: ApplyTransactionOptions) { - this.store = opts.store; - this.media = opts.media; - this.host = opts.host ?? null; - this.cssText = opts.cssText ?? DEFAULT_CSS; - this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000; - this.offline = opts.offline ?? false; - } - - get busy(): boolean { - return this.#busy; - } - - async run(input: ApplyInput): Promise { - if (this.#busy) { - return { - ok: false, - error: "Another background apply is already in progress.", - rolledBack: false, - }; - } - this.#busy = true; - try { - return await this.store.withExclusiveMutation(() => this.#runExclusive(input)); - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : String(error), - rolledBack: false, - }; - } finally { - this.#busy = false; - } - } - - async #runExclusive(input: ApplyInput): Promise { - let snapshot: BackgroundSnapshot | null = null; - let staged: StagedMediaPair | null = null; - let runtimeVideoPath: string | null = null; - try { - await this.store.init(); - snapshot = await this.store.snapshot(); - - const manifest = await this.store.commitImport(input); - staged = await this.#stageMediaFor(manifest); - - if (!this.offline && this.host) { - const payload = await this.#buildPayload( - manifest, - staged, - input.type === "video" ? input.startAt : undefined, - ); - runtimeVideoPath = payload.video?.localPath ?? null; - await this.host.apply(payload); - const verify = await this.host.verify( - { - generation: manifest.generation, - media: manifest.background?.type ?? "clear", - }, - { deadlineMs: this.verifyDeadlineMs }, - ); - if (verify.status !== "pass") { - await this.#rollback(snapshot, staged); - staged = null; - snapshot = null; - return { - ok: false, - error: `Live verify did not pass (${verify.status}): ${verify.reason}`, - rolledBack: true, - }; - } - } - - await this.media.commit(staged); - await this.store.pruneRuntimeMedia(runtimeVideoPath); - staged = null; - if (snapshot) { - await this.store.clearSnapshot(snapshot); - snapshot = null; - } - return { - ok: true, - generation: manifest.generation, - mode: manifest.background?.type ?? "clear", - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - let rolledBack = false; - if (snapshot) { - try { - await this.#rollback(snapshot, staged); - staged = null; - snapshot = null; - rolledBack = true; - } catch { - await this.#abortPair(staged); - staged = null; - } - } else { - await this.#abortPair(staged); - staged = null; - } - return { ok: false, error: message, rolledBack }; - } - } - - async #stageMediaFor(manifest: BackgroundManifest): Promise { - if (!manifest.background) { - return { image: null, video: null }; - } - - const imagePath = path.join( - this.store.paths.activeDir, - manifest.background.image, - ); - const image = await this.media.stage(imagePath); - let video: MediaAssetHandle | null = null; - if (manifest.background.type === "video" && manifest.background.video) { - // DSH streams this handle for the lifetime of the active