mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-14 04:30:51 +08:00
[feat] add DeepSeek Harness sidebar support and setup guides
This commit is contained in:
1 parent
bdce69607f
commit
02a25a759f
15 files changed
+533
-76
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# CrossDesk Sidebar for DeepSeek Harness
|
||||
|
||||
在 DeepSeek Harness **桌面版**中,自动将 CrossDesk MCP 返回的页面打开到发起调用的会话侧栏。适配 Harness 0.2.2-rc.1 的 Sidebar Browser 和 Connection 接口。浏览器版的 iframe 不在支持范围内。
|
||||
|
||||
## 安装
|
||||
|
||||
先按 [DSH 完整安装指南](../../README.md#install-dsh) 安装 CrossDesk MCP 和兼容的原生 CLI,并注册名为 `crossdesk` 的 MCP 服务。**本插件不会自动注册 MCP 服务**;服务配置和插件安装缺一不可。
|
||||
|
||||
使用 MCP 本地安装包时,插件及构建产物已包含在包内:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile desktop add /absolute/path/crossdesk-mcp/node_modules/@chunkdash/crossdesk-mcp/integrations/deepseek-harness
|
||||
```
|
||||
|
||||
从源码运行时,先构建 MCP,再安装工程中的插件目录:
|
||||
|
||||
```sh
|
||||
npm --prefix /absolute/path/crossdesk/apps/mcp run build
|
||||
dsh plugin --profile desktop add /absolute/path/crossdesk/apps/mcp/integrations/deepseek-harness
|
||||
```
|
||||
|
||||
也可在 Harness 的插件页面通过本地路径安装。重启 Harness 和 CrossDesk MCP 后,确认 `crossdesk-sidebar` 组件启用并运行。MCP 的 YAML 配置、源码运行所需的 `--native-binary`、首次连接及排查步骤均见上述完整指南。该包只增加侧栏适配,不连接设备、不修改设备授权。
|
||||
|
||||
不要强制使用 `--panel=apps` 或 `--browser-launch=system`;保留默认 `auto`,或显式使用 `--panel=browser --browser-launch=host`。若服务名称不同,在 Harness profile 的 `cordis.patch.yml` 中添加:
|
||||
|
||||
```yaml
|
||||
- id: crossdesk-sidebar
|
||||
config:
|
||||
serverName: your_crossdesk_server_name
|
||||
```
|
||||
|
||||
## 行为与边界
|
||||
|
||||
- 宿主插件只接收指定 MCP 名称的 `crossdesk_open`、`crossdesk_connect`、`crossdesk_open_browser`、`crossdesk_unlock` 成功结果,验证页面类型及 `127.0.0.1` 启动地址。
|
||||
- 通过 Harness 已认证的 Connection 接口交接页面请求;客户端约每 750 毫秒检查当前会话,只在发起调用的会话中打开侧栏,不读取聊天记录或重新执行工具。
|
||||
- 页面请求在内存中最多保存 60 秒,具有多窗口领取租约及确认机制。相同视图重新签发时替换插件打开的旧标签;切换会话、卸载、过期后不重放历史启动地址。
|
||||
- 地址仅用于导航,不抓取启动页、复制配对令牌或代理视频。CrossDesk 继续使用本机 WebSocket / WebCodecs 播放和现有设备授权;插件不声明页面已经配对或视频已就绪,Agent 仍通过 `crossdesk_get_session` 确认。
|
||||
- Harness 网页版未启用此桥接,因为其 iframe 不能嵌入受保护的 CrossDesk 页面。不会放宽 CrossDesk 的 Origin、CSP、Cookie 或 `X-Frame-Options` 策略。
|
||||
|
||||
该插件与 MCP 一起构建,产物位于本目录的 `dist/`。卸载使用 `dsh plugin --profile desktop remove @chunkdash/dsh-crossdesk-sidebar`。
|
||||
|
||||
2026-11-12 已在 macOS / DeepSeek Harness 0.2.2-rc.1 验证自动打开、配对及 1920 × 1080、约 27–29 FPS 的实时播放;远端为已授权的 Mac mini,仅查看模式。
|
||||
|
||||
宿主接口参考:[Sidebar Browser](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/ui-sidebar-browser)、[Right Sidebar](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/ui-sidebar-right)、[Connection](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/connection)。
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: crossdesk-sidebar
|
||||
name: '@chunkdash/dsh-crossdesk-sidebar'
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@chunkdash/dsh-crossdesk-sidebar",
|
||||
"version": "0.1.0",
|
||||
"description": "Open CrossDesk remote desktop panels in DeepSeek Harness Desktop's sidebar",
|
||||
"type": "module",
|
||||
"main": "dist/host.js",
|
||||
"exports": { ".": "./dist/host.js", "./client": "./dist/client.js", "./package.json": "./package.json" },
|
||||
"engines": { "node": ">=22", "dsh": ">=0.2.2-rc.1 <0.3.0" },
|
||||
"dsh": {
|
||||
"bundle": { "patch": "cordis.patch.yml" },
|
||||
"client": {
|
||||
"inject": ["@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-sidebar-browser"],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "cordis.patch.yml", "README.md"],
|
||||
"license": "GPL-3.0-only"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const inject = ["uiSession", "sidebarRight", "sidebarRightTabs"];
|
||||
|
||||
export function apply(ctx) {
|
||||
// Desktop's isolated webview supports loopback video. A Web iframe cannot
|
||||
// embed CrossDesk's protected page; do not weaken its framing policy.
|
||||
if (globalThis.dshDesktop?.protocolVersion !== 1 || !globalThis.dshDesktop.browser) return;
|
||||
ctx.effect(() => {
|
||||
const lifetime = new AbortController();
|
||||
const client = crypto.randomUUID();
|
||||
const opened = new Map();
|
||||
const views = new Map();
|
||||
const current = ctx.uiSession.adapter.current;
|
||||
let timer;
|
||||
let running = false;
|
||||
const request = async (session, action, id) => {
|
||||
const response = await fetch("/api/crossdesk.sidebar", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action, session_id: session, client_id: client, id }),
|
||||
signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(4000)]),
|
||||
});
|
||||
if (!response.ok) throw new Error("CrossDesk sidebar bridge unavailable");
|
||||
return response.json();
|
||||
};
|
||||
const tick = async () => {
|
||||
if (running || lifetime.signal.aborted) return;
|
||||
clearTimeout(timer);
|
||||
running = true;
|
||||
try {
|
||||
const session = current.getSnapshot().key;
|
||||
if (typeof session !== "string" || !ctx.sidebarRightTabs.get("browser")) return;
|
||||
const { panel } = await request(session, "poll");
|
||||
if (!panel) return;
|
||||
if (current.getSnapshot().key !== session || lifetime.signal.aborted) {
|
||||
if (!lifetime.signal.aborted) await request(session, "release", panel.id);
|
||||
return;
|
||||
}
|
||||
if (panel.session_id !== session || panel.expires_at <= Date.now() ||
|
||||
!/^http:\/\/127\.0\.0\.1:\d+\/launch\/[a-zA-Z0-9_-]{32,128}$/.test(panel.url)) return;
|
||||
for (const [id, expires] of opened) if (expires <= Date.now()) opened.delete(id);
|
||||
if (!opened.has(panel.id)) {
|
||||
const key = `${session}:${panel.view_id}`;
|
||||
const previous = views.get(key);
|
||||
const tabs = ctx.sidebarRight.openTabs.getSnapshot();
|
||||
for (const [view, id] of views) if (!tabs.some(tab => tab.tabId === id)) views.delete(view);
|
||||
const replaceTab = tabs.some(tab => tab.sessionId === session && tab.tabId === previous) ? previous : undefined;
|
||||
ctx.sidebarRight.openTab("browser", { params: { url: panel.url }, ...(replaceTab ? { replaceTab } : {}), revealIfOpened: false });
|
||||
const active = ctx.sidebarRight.active();
|
||||
if (active?.kind !== "browser") throw new Error("CrossDesk sidebar did not open");
|
||||
views.set(key, active.id);
|
||||
opened.set(panel.id, panel.expires_at);
|
||||
}
|
||||
await request(session, "ack", panel.id);
|
||||
} catch {
|
||||
// Retry transient connection/mount failures within the launcher's TTL.
|
||||
// Never log the private launcher URL or consume a second remote session.
|
||||
} finally {
|
||||
running = false;
|
||||
if (!lifetime.signal.aborted) timer = setTimeout(tick, 750);
|
||||
}
|
||||
};
|
||||
const unsubscribe = current.subscribe(() => void tick());
|
||||
void tick();
|
||||
return () => { lifetime.abort(); clearTimeout(timer); unsubscribe(); opened.clear(); views.clear(); };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const name = "crossdesk-sidebar";
|
||||
export const inject = ["connection", "tools"];
|
||||
const route = "/api/crossdesk.sidebar";
|
||||
|
||||
// Only results from this configured MCP namespace can request navigation.
|
||||
export function apply(ctx, config = {}) {
|
||||
const serverName = config.serverName ?? "crossdesk";
|
||||
if (!/^[a-zA-Z0-9_-]{1,24}$/.test(serverName)) throw new Error("Invalid CrossDesk MCP serverName");
|
||||
const tools = new Set(["crossdesk_open", "crossdesk_connect", "crossdesk_open_browser", "crossdesk_unlock"]
|
||||
.map(tool => `mcp__${serverName}__${tool}`));
|
||||
const pending = new Map();
|
||||
const prune = () => {
|
||||
for (const [id, item] of pending) if (item.expires_at <= Date.now()) pending.delete(id);
|
||||
};
|
||||
ctx.on("tools/result", (exec, result) => {
|
||||
if (!tools.has(exec.name) || result.isError || !exec.agent?.session?.id) return;
|
||||
const panel = result.value?.structuredContent;
|
||||
if (panel?.browser_host !== "deepseek-harness" || panel.browser_launch !== "host" ||
|
||||
typeof panel.view_id !== "string" || !/^[a-zA-Z0-9_-]{16,128}$/.test(panel.view_id) ||
|
||||
typeof panel.browser_launch_url !== "string" ||
|
||||
!/^http:\/\/127\.0\.0\.1:\d+\/launch\/[a-zA-Z0-9_-]{32,128}$/.test(panel.browser_launch_url)) return;
|
||||
let url;
|
||||
try { url = new URL(panel.browser_launch_url); } catch { return; }
|
||||
if (url.origin !== panel.browser_url || !Number.isFinite(panel.browser_launch_expires_at_ms)) return;
|
||||
prune();
|
||||
const expires = Math.min(panel.browser_launch_expires_at_ms, Date.now() + 60000);
|
||||
if (expires <= Date.now()) return;
|
||||
const session = exec.agent.session.id;
|
||||
for (const [id, item] of pending)
|
||||
if (item.session_id === session && item.view_id === panel.view_id) pending.delete(id);
|
||||
if (pending.size >= 64) return;
|
||||
const id = randomUUID();
|
||||
pending.set(id, { id, session_id: session, view_id: panel.view_id,
|
||||
url: url.href, expires_at: expires, owner: undefined, leased_until: 0 });
|
||||
});
|
||||
// Connection owns authentication and the application-origin fence. The
|
||||
// bridge never reads credentials, launches processes, or contacts a URL.
|
||||
ctx.connection.fetch.register({
|
||||
path: route, methods: ["POST"], requestBody: "buffered",
|
||||
async fetch(request) {
|
||||
const reply = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } });
|
||||
if (Number(request.headers.get("content-length")) > 2048) return reply({}, 413);
|
||||
let p;
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (body.length > 2048) return reply({}, 413);
|
||||
p = JSON.parse(body);
|
||||
} catch { return reply({}, 400); }
|
||||
if (!p || typeof p.session_id !== "string" || p.session_id.length > 128 ||
|
||||
typeof p.client_id !== "string" || !/^[a-zA-Z0-9_-]{16,80}$/.test(p.client_id)) return reply({}, 400);
|
||||
prune();
|
||||
if (p.action === "poll") {
|
||||
const item = [...pending.values()].find(item => item.session_id === p.session_id &&
|
||||
(!item.owner || item.owner === p.client_id || item.leased_until <= Date.now()));
|
||||
if (!item) return reply({});
|
||||
item.owner = p.client_id;
|
||||
item.leased_until = Date.now() + 5000;
|
||||
const { owner, leased_until, ...panel } = item;
|
||||
return reply({ panel });
|
||||
}
|
||||
if (p.action !== "ack" && p.action !== "release") return reply({}, 400);
|
||||
const item = pending.get(p.id);
|
||||
if (item?.session_id === p.session_id && item.owner === p.client_id) {
|
||||
if (p.action === "ack") pending.delete(p.id);
|
||||
else { item.owner = undefined; item.leased_until = 0; }
|
||||
}
|
||||
return reply({});
|
||||
},
|
||||
});
|
||||
ctx.effect(() => () => pending.clear());
|
||||
}
|
||||
Reference in new issue
Block a user