diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4f61bd5..297e803 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,16 @@ # Third-party notices +## DeepSeek Harness bundled runtime + +- Package: `@deepseek-ai/dsh@0.1.0-rc.6` +- License: MIT, Copyright (c) 2026 DeepSeek +- Upstream project: https://github.com/deepseek-ai/deepseek-harness +- Distribution: the Windows installer includes the npm runtime and its + dependency tree in a private, versioned directory. beautiCode does not install + DSH globally or modify the upstream package. +- The upstream DSH license is copied to `licenses/dsh/LICENSE`; dependency + package licenses remain alongside their packages in the bundled runtime. + ## Fei-Away/Codex-Dream-Skin (media server lineage) - License: MIT diff --git a/docs/deepseek-harness.md b/docs/deepseek-harness.md new file mode 100644 index 0000000..4e2763d --- /dev/null +++ b/docs/deepseek-harness.md @@ -0,0 +1,70 @@ +# DeepSeek Harness 集成 + +beautiCode 通过 DeepSeek Harness 的 `webServer` 插件接口接入,不修改 DSH 源码,也不依赖 Chromium 调试端口。当前实现按 npm `@deepseek-ai/dsh@0.1.0-rc.6` 的接口验证;DSH 升级后应重新执行真实页面验收。 + +## 已实现能力 + +- 图片背景、MP4 视频背景与清除。 +- 视频默认静音;可请求开启声音。若 Chromium 自动播放策略阻止开启声音,会继续静音播放并返回 `blocked: true`。 +- 视频播放位置随已保存主题记录;切换主题、重新应用与页面恢复时从最近位置继续。 +- 摸鱼模式:隐藏 DSH 的 `#root`,背景舞台继续显示和播放;`Ctrl+Shift+Space` 可退出。 +- 深色、浅色、跟随系统三种背景色调。 +- 图片/视频主题的保存、切换和删除。 +- 页面刷新或稍后打开时,托盘会检测代际不一致并恢复当前背景与模式。 + +同一个 beautiCode 数据目录一次只能运行一个宿主会话,避免 Codex 与 DSH 同时写入造成状态损坏。 + +## 推荐启动方式 + +从开始菜单或桌面启动 beautiCode,在弹出的目标应用选择框中选择 DeepSeek Harness。也可以使用开始菜单中的“beautiCode · DeepSeek Harness”专用入口跳过选择框。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File C:\WoRk\beautiCode\scripts\start-beauticode-dsh.ps1 +``` + +第三阶段安装包已内置经完整性校验的 `@deepseek-ai/dsh@0.1.0-rc.6` 和 Node.js,用户不需要全局安装 Node/npm/DSH。源码环境没有兼容运行时时,启动器只会安装到 beautiCode 数据目录的 `dsh-runtime`,不会执行全局 npm 安装。 + +启动器会: + +1. 校验本地 DSH 的版本、npm SHA-512 integrity 和实际 CLI 版本。 +2. 将桥接按内容哈希发布到独立、不可变的版本目录;安装包升级后,下次启动会自动采用新插件。 +3. 检查目标地址的桥接协议与内容版本;完全一致时复用现有 DSH,不会重启它。 +4. 地址无人监听时,用 beautiCode 私有运行时一键启动 `dsh web`,等待桥接就绪后打开页面和托盘。 +5. 若端口已有旧桥接或未加载桥接,停止并给出错误;不会中断现有会话。用户关闭该 DSH 后再次启动即可加载新插件。 +6. 每 24 小时最多查询一次 npm 最新版本。只提示新版本,不会把未经回归验证的 DSH 自动升级为兼容版本。 + +可选参数: + +```powershell +# 使用其他本机端口和数据目录 +.\scripts\start-beauticode-dsh.ps1 -DshUrl http://127.0.0.1:31880 -DataRoot C:\beautiCode-data + +# 只确保桥接已启动,不打开浏览器和托盘(诊断/自动化) +.\scripts\start-beauticode-dsh.ps1 -NoBrowser -EnsureBridgeOnly + +# 输出本地、兼容、npm 最新及正在运行的桥接版本(JSON) +.\scripts\start-beauticode-dsh.ps1 -VersionOnly + +# 离线启动,不查询 npm 最新版本 +.\scripts\start-beauticode-dsh.ps1 -NoVersionCheck +``` + +退出 beautiCode 托盘不会结束由启动器创建的 DSH 进程,避免中断 DSH 中的工作;再次运行启动器会复用它。 + +## 手动启动 + +如需自行管理 DSH,可参考 [`integrations/deepseek-harness/cordis.patch.example.yml`](../integrations/deepseek-harness/cordis.patch.example.yml),把其中的文件 URL 改为本机 `index.mjs` 的绝对地址,再运行: + +```powershell +dsh.cmd web --patch C:\完整路径\beauticode.patch.yml --port 3080 +``` + +自定义 beautiCode 数据目录时,DSH 进程与托盘必须使用同一个 `BEAUTICODE_DATA_ROOT`,否则令牌文件不一致。 + +## 安全边界 + +- DSH 地址只接受 `http://127.0.0.1`、`http://localhost` 或 `http://[::1]`。 +- 控制请求使用数据目录内的 256 位随机令牌;浏览器回执只接受同源请求。 +- 图片与 MP4 由随机端口的本机媒体服务提供,URL 带不可预测令牌并校验 DSH 页面来源。 +- 只有浏览器真实加载/解码媒体并回执后,应用事务才成功;否则磁盘状态回滚。 +- 自动化测试不能替代发布前的真实 DSH 页面可见性验收。 diff --git a/integrations/deepseek-harness/README.zh-CN.md b/integrations/deepseek-harness/README.zh-CN.md new file mode 100644 index 0000000..8921df9 --- /dev/null +++ b/integrations/deepseek-harness/README.zh-CN.md @@ -0,0 +1,20 @@ +# DeepSeek Harness 桥接插件 + +该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式、深浅色调以及清除背景。 + +推荐直接运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File C:\WoRk\beautiCode\scripts\start-beauticode-dsh.ps1 +``` + +启动器使用安装包内置或 beautiCode 私有目录中的兼容 DSH,按桥接内容哈希发布不可变插件版本,动态生成补丁并使用独立 DSH_HOME。若目标端口已有旧桥接或未加载桥接的 DSH,它不会自动重启或结束该进程;关闭该 DSH 后再次启动即可自动采用新插件。 + +手动接入时: + +1. 修改 `cordis.patch.example.yml` 中的 `file:///.../index.mjs`。 +2. 确保 DSH 和 beautiCode 使用相同的 `BEAUTICODE_DATA_ROOT`。 +3. 运行 `dsh web --patch <补丁路径> --port 3080`。 +4. 打开页面后从 beautiCode 托盘应用背景。 + +安全边界:DSH Web 必须绑定本机回环地址;控制端点需要随机令牌;媒体 URL 仅允许带令牌的回环 HTTP 地址;浏览器回执只接受同源请求。 diff --git a/integrations/deepseek-harness/client.js b/integrations/deepseek-harness/client.js new file mode 100644 index 0000000..2c5ecdb --- /dev/null +++ b/integrations/deepseek-harness/client.js @@ -0,0 +1,326 @@ +(() => { + "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: "dark" }; + let activePayload = null; + let playbackBlocked = false; + 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,.26); + --dsw-alias-bg-layer-1:rgba(26,30,39,.58); + --dsw-alias-bg-layer-2:rgba(35,40,51,.68); + --dsw-alias-bg-overlay:rgba(17,20,27,.74); + --dsw-specific-sidebar-fill:rgba(23,27,35,.64); +} +html[data-bc-resolved-tone="light"][data-bc-active="true"] body{ + --dsw-alias-bg-base:rgba(248,250,252,.34); + --dsw-alias-bg-layer-1:rgba(255,255,255,.64); + --dsw-alias-bg-layer-2:rgba(248,250,252,.72); + --dsw-alias-bg-overlay:rgba(255,255,255,.76); + --dsw-specific-sidebar-fill:rgba(255,255,255,.68); +} +#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:rgba(0,0,0,.24);pointer-events:none} +html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc} +html[data-bc-resolved-tone="light"] #beauticode-bg-stage::after{background:rgba(255,255,255,.10)} +html[data-bc-active="true"]:has(#root [data-phase="hero"]) #beauticode-bg-stage::after{background:transparent} +#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-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} +`; + document.head.append(style); + + function resolvedTone() { + if (desiredModes.tone !== "auto") return desiredModes.tone; + return systemDarkMedia?.matches ? "dark" : "light"; + } + + function isDshThemeSynced(tone = resolvedTone()) { + const body = document.body; + if (!body) return false; + return ( + body.hasAttribute("data-ds-dark-theme") === (tone === "dark") && + document.documentElement.style.colorScheme === tone + ); + } + + function syncDshTheme() { + const tone = resolvedTone(); + const root = document.documentElement; + const body = document.body; + root.dataset.bcResolvedTone = tone; + if (root.style.colorScheme !== tone) root.style.colorScheme = tone; + if (body) body.toggleAttribute("data-ds-dark-theme", tone === "dark"); + 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(); + } + }); + 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; + } + + 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, + }; + } + + 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, + }); + } + + 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 loadImage(url) { + const image = new Image(); + image.alt = ""; + image.crossOrigin = "anonymous"; + image.decoding = "async"; + return new Promise((resolve, reject) => { + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("图片加载失败")); + image.src = url; + }); + } + + function waitForVideo(video) { + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve(); + return new Promise((resolve, reject) => { + const done = () => { + cleanup(); + resolve(); + }; + const failed = () => { + cleanup(); + reject(new Error("MP4 加载或解码失败")); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", done); + video.removeEventListener("error", failed); + }; + video.addEventListener("loadeddata", done, { once: true }); + video.addEventListener("error", failed, { once: true }); + }); + } + + 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 { + video.currentTime = 0; + } + } + + async function playWithPreference(video) { + playbackBlocked = false; + video.muted = desiredModes.muted; + try { + await video.play(); + } catch (error) { + if (desiredModes.muted) throw error; + playbackBlocked = true; + video.muted = true; + await video.play(); + } + } + + async function applyBackground(payload) { + if (!Number.isSafeInteger(payload?.generation)) return; + activePayload = payload; + document.documentElement.dataset.bcGeneration = String(payload.generation); + 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"); + document.getElementById("beauticode-bg-stage")?.remove(); + await acknowledgeRender(payload, true, false); + await acknowledgeMode(); + return; + } + if (typeof payload.imageUrl !== "string") return; + try { + const image = await loadImage(payload.imageUrl); + if (activePayload !== payload) return; + const node = stage(); + 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 : "图片尺寸无效"); + await acknowledgeMode(); + return; + } + if (payload.media !== "video" || typeof payload.videoUrl !== "string") return; + const video = document.createElement("video"); + video.autoplay = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.poster = payload.imageUrl; + video.crossOrigin = "anonymous"; + 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"; + 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; + await acknowledgeRender( + payload, + false, + false, + 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"; + } else { + document.documentElement.removeAttribute("data-bc-fish"); + } + const video = activeVideo(); + 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 { + 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. */ + } + }; + + setInterval(() => { + const video = activeVideo(); + if (activePayload?.media === "video" && video instanceof HTMLVideoElement) { + void acknowledgeRender(activePayload, video.readyState >= 2 && !video.paused, true); + } + }, 1_000); +})(); diff --git a/integrations/deepseek-harness/compatibility.json b/integrations/deepseek-harness/compatibility.json new file mode 100644 index 0000000..ae64a06 --- /dev/null +++ b/integrations/deepseek-harness/compatibility.json @@ -0,0 +1,8 @@ +{ + "schema": "beauticode.dsh-compatibility/v1", + "package": "@deepseek-ai/dsh", + "supportedVersion": "0.1.0-rc.6", + "integrity": "sha512-brpZfED7ieRa2PQ5tUxMhHrM1pb2CmKFVM/f6yMULBDMicahk+Z2OsHgTwTDnoiZm23Ftu9rQz0NN4pflaoJcg==", + "bridgeProtocol": 4, + "updatePolicy": "report-newer-only" +} diff --git a/integrations/deepseek-harness/cordis.patch.example.yml b/integrations/deepseek-harness/cordis.patch.example.yml new file mode 100644 index 0000000..072d309 --- /dev/null +++ b/integrations/deepseek-harness/cordis.patch.example.yml @@ -0,0 +1,5 @@ +# Manual-use example. The launcher generates this patch for the actual install path. +- insert: + - id: beauticode-bridge + name: 'file:///C:/WoRk/beautiCode/integrations/deepseek-harness/index.mjs' + inject: [webServer] diff --git a/integrations/deepseek-harness/index.mjs b/integrations/deepseek-harness/index.mjs new file mode 100644 index 0000000..3bc59ba --- /dev/null +++ b/integrations/deepseek-harness/index.mjs @@ -0,0 +1,439 @@ +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"; + +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"; +} + +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; + } +} + +function validApplyPayload(body) { + if (!Number.isSafeInteger(body.generation) || body.generation < 0) return false; + if (!["image", "video", "clear"].includes(body.media)) return false; + if (body.media === "clear") { + return body.imageUrl == null && body.videoUrl == null && body.startAt == null; + } + if (!validLoopbackMediaUrl(body.imageUrl)) return false; + if (body.media === "image") return body.videoUrl == null && body.startAt == null; + return ( + validLoopbackMediaUrl(body.videoUrl) && + (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 = {}) { + 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: "dark" }; + + 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/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, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + 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`); + 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, + imageUrl: body.media === "clear" ? null : body.imageUrl, + videoUrl: body.media === "video" ? body.videoUrl : null, + startAt: body.media === "video" ? body.startAt ?? null : null, + }; + 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(); + for (const response of clients.values()) response.destroy(); + clients.clear(); + clientStates.clear(); + }; + }, "beauticode-bridge: routes and browser injection"); +} diff --git a/integrations/deepseek-harness/test/plugin.test.mjs b/integrations/deepseek-harness/test/plugin.test.mjs new file mode 100644 index 0000000..2e711b1 --- /dev/null +++ b/integrations/deepseek-harness/test/plugin.test.mjs @@ -0,0 +1,353 @@ +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); + 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="hero"\]\)/); + assert.match(source, /data-bc-resolved-tone/); + assert.match(source, /toggleAttribute\("data-ds-dark-theme"/); + assert.match(source, /prefers-color-scheme: dark/); + assert.match(source, /new MutationObserver\(scheduleDshThemeSync\)/); + + 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 keeps the DSH palette synchronized with beautiCode tone", 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: "" }, + removeAttribute(name) { + if (name === "data-bc-fish") delete this.dataset.bcFish; + }, + }; + const media = { + matches: false, + 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); + + const applyTone = async (tone) => { + events.onmessage({ + data: JSON.stringify({ type: "mode", fish: false, muted: true, tone }), + }); + await new Promise((resolve) => setImmediate(resolve)); + }; + + await applyTone("light"); + assert.equal(documentElement.dataset.bcResolvedTone, "light"); + assert.equal(documentElement.style.colorScheme, "light"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), false); + + await applyTone("dark"); + 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"; + observerCallback(); + assert.equal(documentElement.style.colorScheme, "dark"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), true); + + await applyTone("auto"); + assert.equal(documentElement.dataset.bcResolvedTone, "light"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), false); + media.matches = true; + media.listener(); + assert.equal(documentElement.dataset.bcResolvedTone, "dark"); + assert.equal(documentElement.style.colorScheme, "dark"); + assert.equal(body.hasAttribute("data-ds-dark-theme"), 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, + visibleClients: 1, + modeReadyClients: 0, + blockedClients: 0, + resolvedTone: null, + modes: { fish: false, muted: true, tone: "dark" }, + playback: null, + }); +}); + +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); +}); diff --git a/package-lock.json b/package-lock.json index d1a510d..d57d333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,10 @@ "resolved": "packages/adapter-codex", "link": true }, + "node_modules/@beauticode/adapter-dsh": { + "resolved": "packages/adapter-dsh", + "link": true + }, "node_modules/@beauticode/core": { "resolved": "packages/core", "link": true @@ -104,6 +108,21 @@ "node": ">=22" } }, + "packages/adapter-dsh": { + "name": "@beauticode/adapter-dsh", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@beauticode/core": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=22" + } + }, "packages/core": { "name": "@beauticode/core", "version": "0.1.0", diff --git a/package.json b/package.json index 4fea665..a81b88b 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "apps/*" ], "scripts": { - "build": "npm run build -w @beauticode/core && npm run build -w @beauticode/adapter-codex", - "test": "npm run test -w @beauticode/core && npm run test -w @beauticode/adapter-codex", - "typecheck": "npm run build -w @beauticode/core && npm run typecheck -w @beauticode/adapter-codex", + "build": "npm run build -w @beauticode/core && npm run build -w @beauticode/adapter-codex && npm run build -w @beauticode/adapter-dsh", + "test": "npm run test -w @beauticode/core && npm run test -w @beauticode/adapter-codex && npm run test -w @beauticode/adapter-dsh", + "typecheck": "npm run build -w @beauticode/core && npm run typecheck -w @beauticode/adapter-codex && npm run typecheck -w @beauticode/adapter-dsh", "discover": "node scripts/beauticode.mjs discover", "bc": "node scripts/beauticode.mjs", "smoke:live": "npm run build && node scripts/live-smoke.mjs", "tray": "npm run build && powershell -NoProfile -ExecutionPolicy Bypass -File apps/tray/start-tray.ps1 -SkipBuild", + "tray:dsh": "npm run build && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/start-beauticode-dsh.ps1 -SkipBuild", "installer:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-installer.ps1" }, "engines": { diff --git a/packages/adapter-dsh/package.json b/packages/adapter-dsh/package.json new file mode 100644 index 0000000..465868e --- /dev/null +++ b/packages/adapter-dsh/package.json @@ -0,0 +1,30 @@ +{ + "name": "@beauticode/adapter-dsh", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test test/**/*.test.js ../../integrations/deepseek-harness/test/*.test.mjs" + }, + "dependencies": { + "@beauticode/core": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=22" + }, + "license": "MIT" +} diff --git a/packages/adapter-dsh/src/bridge.ts b/packages/adapter-dsh/src/bridge.ts new file mode 100644 index 0000000..cb19919 --- /dev/null +++ b/packages/adapter-dsh/src/bridge.ts @@ -0,0 +1,292 @@ +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; +} + +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(/\/+$/, "") || "/"; + 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."); + } + await this.#request("apply", { + method: "POST", + body: JSON.stringify({ + 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, + }), + }); + } + + 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.", + }; + } +} diff --git a/packages/adapter-dsh/src/host-descriptor.ts b/packages/adapter-dsh/src/host-descriptor.ts new file mode 100644 index 0000000..ed728d6 --- /dev/null +++ b/packages/adapter-dsh/src/host-descriptor.ts @@ -0,0 +1,16 @@ +import type { HostDescriptor } from "@beauticode/core"; + +export const DSH_HOST_DESCRIPTOR: HostDescriptor = Object.freeze({ + kind: "dsh", + displayName: "DeepSeek Harness", + capabilities: Object.freeze({ + image: true, + clear: true, + reapply: true, + savedThemes: true, + video: true, + fish: true, + muted: true, + tone: true, + }), +}); diff --git a/packages/adapter-dsh/src/index.ts b/packages/adapter-dsh/src/index.ts new file mode 100644 index 0000000..851f79b --- /dev/null +++ b/packages/adapter-dsh/src/index.ts @@ -0,0 +1,14 @@ +export { + DshHostApplier, + 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"; diff --git a/packages/adapter-dsh/src/injector-lock.ts b/packages/adapter-dsh/src/injector-lock.ts new file mode 100644 index 0000000..4d2ec47 --- /dev/null +++ b/packages/adapter-dsh/src/injector-lock.ts @@ -0,0 +1,28 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { acquireFileLock } from "@beauticode/core"; + +/** Shares injector.lock with the Codex adapter to prevent cross-host writes. */ +export async function acquireDshInjectorLock( + dataRoot: string, +): Promise<() => Promise> { + await fs.mkdir(dataRoot, { recursive: true }); + const file = path.join(dataRoot, "injector.lock"); + const lease = await acquireFileLock(file, { purpose: "beautiCode injector" }); + const lock = { + pid: lease.owner.pid, + port: 0, + host: "dsh", + startedAt: lease.owner.startedAt, + nonce: lease.owner.nonce, + }; + const handle = await fs.open(file, "r+"); + try { + await handle.truncate(0); + await handle.writeFile(`${JSON.stringify(lock, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + return () => lease.release(); +} diff --git a/packages/adapter-dsh/src/session.ts b/packages/adapter-dsh/src/session.ts new file mode 100644 index 0000000..a5b99fd --- /dev/null +++ b/packages/adapter-dsh/src/session.ts @@ -0,0 +1,530 @@ +import path from "node:path"; +import { + ApplyTransaction, + BackgroundStore, + MediaServerController, + buildHostApplyPayload, + defaultDataRoot, + 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"; + +export interface DshSessionOptions { + baseUrl?: string; + dataRoot?: string; + verifyDeadlineMs?: number; + pollMs?: number; + onError?: (err: Error) => void; + onStatus?: (msg: string) => void; +} + +export class DshSession implements HostSession { + readonly descriptor = DSH_HOST_DESCRIPTOR; + readonly cdpPort = null; + readonly dataRoot: string; + readonly verifyDeadlineMs: number; + readonly pollMs: number; + 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 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 = "dark"; + 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.baseUrl = normalizeDshBaseUrl(opts.baseUrl ?? "http://127.0.0.1:3080"); + this.store = new BackgroundStore({ root: this.dataRoot }); + 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(); + 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); + } + 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; + 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 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/src/token.ts b/packages/adapter-dsh/src/token.ts new file mode 100644 index 0000000..5cf142e --- /dev/null +++ b/packages/adapter-dsh/src/token.ts @@ -0,0 +1,46 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DSH_BRIDGE_TOKEN_FILE = "dsh-bridge.token"; +const TOKEN_PATTERN = /^[a-f0-9]{64}$/; + +export function bridgeTokenPath(dataRoot: string): string { + return path.join(dataRoot, DSH_BRIDGE_TOKEN_FILE); +} + +async function readToken(file: string): Promise { + const token = (await fs.readFile(file, "utf8")).trim(); + if (!TOKEN_PATTERN.test(token)) { + throw new Error("DeepSeek Harness bridge token file is invalid."); + } + return token; +} + +/** Create once with owner-only permissions where the platform supports them. */ +export async function ensureBridgeToken(dataRoot: string): Promise { + await fs.mkdir(dataRoot, { recursive: true }); + const file = bridgeTokenPath(dataRoot); + try { + return await readToken(file); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } + + const token = crypto.randomBytes(32).toString("hex"); + try { + const handle = await fs.open(file, "wx", 0o600); + try { + await handle.writeFile(`${token}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "EEXIST") { + return readToken(file); + } + throw error; + } +} diff --git a/packages/adapter-dsh/test/adapter.test.js b/packages/adapter-dsh/test/adapter.test.js new file mode 100644 index 0000000..02c77e4 --- /dev/null +++ b/packages/adapter-dsh/test/adapter.test.js @@ -0,0 +1,315 @@ +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, + 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)), + }; +} + +test("DSH URL only accepts loopback HTTP", () => { + assert.equal(normalizeDshBaseUrl("http://127.0.0.1:3080").origin, "http://127.0.0.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); +}); + +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 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.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, + ); +}); diff --git a/packages/adapter-dsh/tsconfig.json b/packages/adapter-dsh/tsconfig.json new file mode 100644 index 0000000..3171a88 --- /dev/null +++ b/packages/adapter-dsh/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}