mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-12 03:28:20 +08:00
906 lines
46 KiB
TypeScript
906 lines
46 KiB
TypeScript
import type { App } from "@modelcontextprotocol/ext-apps";
|
||
import "./viewer.css";
|
||
import { AudioPlayer } from "./audio-player.js";
|
||
import { HumanInput } from "./human-input.js";
|
||
import { VideoPlayer, type FrameInfo } from "./video-player.js";
|
||
import { PreviewPlayer } from "./preview-player.js";
|
||
|
||
declare const CROSSDESK_APPS: boolean;
|
||
declare const CROSSDESK_LOGO: string;
|
||
type Data = Record<string, any>;
|
||
type Invoke = (name: string, args?: Data) => Promise<Data>;
|
||
const icons = {
|
||
monitor: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/>',
|
||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v1"/>',
|
||
volume: '<path d="m11 4-6 5H2v6h3l6 5V4Zm4 4a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/>',
|
||
mouse: '<rect x="6" y="2" width="12" height="20" rx="6"/><path d="M12 2v6"/>',
|
||
pause: '<path d="M8 5v14m8-14v14"/>',
|
||
power: '<path d="M12 2v10m6-7a9 9 0 1 1-12 0"/>',
|
||
expand: '<path d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/>',
|
||
arrow: '<path d="M4 12h16m-6-6 6 6-6 6"/>',
|
||
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||
};
|
||
const icon = (name: keyof typeof icons) => `<svg class="icon icon-${name}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name]}</svg>`;
|
||
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||
root.innerHTML = `
|
||
<header class="app-header">
|
||
<div class="brand"><img class="brand-logo" src="${CROSSDESK_LOGO}" alt="" width="24" height="24"><h1>CrossDesk</h1><span class="brand-context">Agent 会话</span></div>
|
||
<div class="header-right"><button id="details-toggle" class="quiet" aria-expanded="false" aria-controls="sidebar">${icon("info")}<span id="details-label">会话详情</span></button><span class="connection" id="connection" role="status"><span id="connection-label">等待连接</span></span></div>
|
||
</header>
|
||
<div id="error" role="alert" hidden></div>
|
||
<main>
|
||
<button id="details-backdrop" class="details-backdrop" aria-label="关闭会话详情" tabindex="-1" hidden></button>
|
||
<aside id="sidebar" aria-labelledby="sidebar-title">
|
||
<div class="sidebar-heading"><h2 id="sidebar-title">授权 Agent 连接</h2><button id="details-close" class="quiet icon-button" aria-label="关闭会话详情">×</button></div>
|
||
<div id="connect-form" role="form" aria-label="连接远程电脑">
|
||
<p class="authorization-note">选择设备并授权后,Agent 将继续任务。你可以在这里查看画面或暂停控制。</p>
|
||
<fieldset id="connection-fields">
|
||
<label for="device">设备</label><select id="device"><option value="">连接新设备</option></select>
|
||
<div id="new-device">
|
||
<label for="device-id">设备 ID</label><input id="device-id" inputmode="numeric" autocomplete="off" placeholder="输入 9 位设备 ID" maxlength="11">
|
||
</div>
|
||
<label for="password">访问密码</label><div class="password-field"><input id="password" type="password" autocomplete="off" maxlength="256" placeholder="输入远端访问密码"><button type="button" id="password-toggle" aria-label="显示密码" aria-pressed="false">显示</button></div>
|
||
<label for="mode">授权范围</label><select id="mode" aria-describedby="mode-description"><option value="observe">仅查看画面</option><option value="control">允许 Agent 操作</option></select>
|
||
<p class="field-hint" id="mode-description">Agent 可以查看屏幕,无法操作键鼠。</p>
|
||
<details class="preferences"><summary>保存与授权</summary>
|
||
<div id="new-device-label"><label for="label">设备备注 <span class="optional">可选</span></label><input id="label" placeholder="例如:工作电脑" maxlength="80"></div>
|
||
<div class="checks">
|
||
<label><input id="save" type="checkbox"><span>保存设备</span></label>
|
||
<label><input id="remember" type="checkbox" disabled><span>记住密码<small>保存在本机系统凭据库</small></span></label>
|
||
<label><input id="remember-unlock" type="checkbox" disabled><span>保存锁屏密码并允许自动解锁<small>独立保存在本机系统凭据库</small></span></label>
|
||
<label><input id="authorize" type="checkbox" disabled><span>允许下次自动连接<small>沿用本次授权范围</small></span></label>
|
||
</div>
|
||
<div id="saved-unlock-field" hidden><label for="saved-unlock-password">系统锁屏密码</label><input id="saved-unlock-password" type="password" autocomplete="off" maxlength="256"></div>
|
||
</details>
|
||
<details id="advanced"><summary>高级设置</summary>
|
||
<label for="server">服务器</label><input id="server" value="api.crossdesk.cn" autocomplete="off">
|
||
<div class="row"><div><label for="port">端口</label><input id="port" type="number" min="1" max="65535" value="9099"></div><div><label for="display">显示器索引</label><input id="display" type="number" min="0" max="63" value="0"></div></div>
|
||
</details>
|
||
</fieldset>
|
||
<button id="connect" type="button" class="primary" disabled><span id="connect-label">授权并连接</span>${icon("arrow")}</button>
|
||
<p class="privacy">密码不会发送到 Agent 对话中。</p>
|
||
</div>
|
||
<div id="session-card" hidden>
|
||
<div class="device-card"><div class="device-icon">${icon("monitor")}</div><div class="device-identity"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></div></div>
|
||
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div><div><dt>锁屏密码</dt><dd id="session-unlock-credential"></dd></div><div id="session-unlock-row" hidden><dt>解锁状态</dt><dd class="session-unlock-details"><span id="session-unlock-state"></span></dd></div></dl>
|
||
<label class="check"><input id="recovery" type="checkbox"><span>断线自动恢复</span></label>
|
||
<p class="session-note">关闭页面不会断开连接。</p>
|
||
</div>
|
||
</aside>
|
||
<section class="workspace" aria-label="远程画面与会话控制">
|
||
<div class="viewer-card">
|
||
<div class="viewer-toolbar">
|
||
<div class="viewer-title"><h2 id="device-name">远程画面</h2><span id="agent-state" role="status">等待授权</span></div>
|
||
<div class="viewer-controls">
|
||
<button id="return-agent" class="soft" hidden>${icon("arrow")}<span>交还 Agent</span></button>
|
||
<button id="pause" title="暂停 Agent 控制" hidden disabled>${icon("pause")}<span id="pause-label">暂停 Agent</span></button>
|
||
<button id="more-toggle" class="quiet icon-button" aria-label="会话选项" title="会话选项" aria-expanded="false" aria-controls="session-menu" aria-haspopup="dialog">${icon("more")}</button>
|
||
</div>
|
||
</div>
|
||
<div id="attention" class="attention" role="status" hidden><span>Agent 等待系统解锁</span><button id="unlock-open" type="button" hidden>输入密码</button></div>
|
||
<div class="screen" id="screen">
|
||
<canvas id="frame" role="img" aria-label="远程桌面实时画面" hidden></canvas>
|
||
<canvas id="agent-cursor" aria-hidden="true"></canvas>
|
||
<textarea id="remote-input" aria-label="远程键盘输入" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||
<div class="control-halo" aria-hidden="true"></div>
|
||
<div id="empty"><div class="empty-icon">${icon("monitor")}</div><h3 id="empty-title">未连接设备</h3><p id="empty-description">选择设备并输入访问密码。</p></div>
|
||
<div class="stale" id="stale" role="status" hidden>画面暂未更新</div>
|
||
</div>
|
||
<div class="telemetry"><span id="video-state">待连接</span><div class="stream-metrics"><span id="resolution">—</span><span id="fps">—</span></div><span id="session-info">等待设备连接</span></div>
|
||
</div>
|
||
<div class="video-options"><button id="video-retry" hidden>重新加载画面</button><button id="video-browser" hidden>在浏览器中观看</button></div>
|
||
<p id="panel-status" class="panel-status" role="status" hidden></p>
|
||
</section>
|
||
</main>
|
||
<div id="session-menu" class="session-menu" role="dialog" aria-label="会话选项" hidden>
|
||
<p class="menu-caption">查看与人工干预</p>
|
||
<button id="focus-view" class="quiet" aria-label="放大画面" aria-pressed="false" disabled>${icon("expand")}<span id="focus-label">放大画面</span></button>
|
||
<button id="take-over" class="quiet" disabled>${icon("mouse")}<span id="take-over-label">人工接管</span></button>
|
||
<div class="menu-audio"><button id="audio-play" class="quiet" aria-label="播放声音" aria-pressed="false" disabled>${icon("volume")}<span id="audio-label">播放声音</span></button><label class="audio-volume"><span>音量</span><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="1" disabled></label></div>
|
||
<button id="disconnect" class="quiet danger" disabled>${icon("power")}<span>断开连接</span></button>
|
||
<p class="menu-note">断开会结束当前远程会话。</p>
|
||
</div>
|
||
<dialog id="unlock-dialog" class="unlock-dialog" aria-labelledby="unlock-title" aria-describedby="unlock-status">
|
||
<div class="unlock-dialog-header"><h2 id="unlock-title">解锁远程电脑</h2><button id="unlock-close" type="button" aria-label="关闭解锁弹窗">×</button></div>
|
||
<p id="unlock-status" role="status"></p>
|
||
<form id="unlock-form">
|
||
<label for="unlock-password">系统锁屏密码</label><input id="unlock-password" type="password" autocomplete="off" maxlength="256" required autofocus>
|
||
<div class="checks"><label><input id="unlock-remember" type="checkbox"><span>保存密码并允许以后自动解锁<small>独立保存在本机系统凭据库</small></span></label></div>
|
||
<div class="unlock-buttons"><button id="unlock-submit" class="primary" type="submit">解锁</button><button id="unlock-cancel" type="button">取消解锁</button></div>
|
||
</form>
|
||
</dialog>
|
||
<div id="unlock-toast" class="unlock-toast" role="status" hidden><div><strong id="unlock-toast-title"></strong><p id="unlock-toast-message"></p></div><button id="unlock-toast-close" type="button" aria-label="关闭解锁提示">×</button></div>`;
|
||
const element = <T extends HTMLElement = HTMLElement>(id: string) =>
|
||
document.getElementById(id) as T;
|
||
const input = (id: string) => element<HTMLInputElement>(id);
|
||
const select = (id: string) => element<HTMLSelectElement>(id);
|
||
let invoke: Invoke;
|
||
let app: App | undefined;
|
||
let context: Data | undefined;
|
||
let status: Data = {};
|
||
let savedDevices: Data[] = [];
|
||
let controlBusy = false;
|
||
let unlockBusy = false;
|
||
let unlockRequestId: string | undefined;
|
||
let unlockSessionId: string | undefined;
|
||
let unlockState: string | undefined;
|
||
let unlockError = "";
|
||
let unlockToastTimer: ReturnType<typeof setTimeout> | undefined;
|
||
let disconnectedByUser = false;
|
||
let frameSize = "", frameRate = "";
|
||
let player: VideoPlayer | PreviewPlayer | undefined;
|
||
let usePreview = false;
|
||
let videoStarting = false, videoFailed = false, videoEpoch = 0;
|
||
let videoSession: string | undefined;
|
||
let videoGeneration: number | undefined;
|
||
let imageAt = 0;
|
||
let busy = false,
|
||
polling = false,
|
||
started = false;
|
||
const audioPlayer = new AudioPlayer();
|
||
const humanInput = new HumanInput(element("screen"), element<HTMLTextAreaElement>("remote-input"),
|
||
(args) => invoke("input", args), showError);
|
||
function ownsInput() { return status.control_owner === "human" && status.human_view === context?.view_id; }
|
||
const messages: Record<string, string> = {
|
||
AUDIO_INVALID: "远端音频格式无效。",
|
||
AUDIO_UNAVAILABLE: "尚未收到远端声音,请检查远端音频捕获权限。",
|
||
HUMAN_CONTROL_ACTIVE: "远端正在由人工操作,请先交还 Agent。",
|
||
STALE_OBSERVATION: "画面已变化,请等待新画面后继续操作。",
|
||
STALE_CONTROL: "控制权已交接。",
|
||
INPUT_QUEUE_FULL: "输入过快,已释放按键,请稍后继续。",
|
||
NATIVE_INPUT_UNSUPPORTED: "请更新原生 CLI 以启用人工接管。",
|
||
VIDEO_UNSUPPORTED: "当前浏览器不支持此视频格式,请使用支持 WebCodecs 的浏览器。",
|
||
VIDEO_UNAVAILABLE: "视频通道暂不可用。可重新加载画面;内嵌面板受限时请在浏览器中观看。",
|
||
VIDEO_POLICY_BLOCKED: "宿主的安全策略阻止了实时视频连接。",
|
||
VIDEO_INVALID: "视频格式发生变化,请重新加载画面。",
|
||
VIDEO_LIMIT: "观看面板数量已达上限,请关闭其他面板。",
|
||
AUTH_FAILED: "访问密码错误,请重新输入。",
|
||
AUTH_REQUIRED: "请输入访问密码,或在本机 setup 中配置凭据。",
|
||
CONNECTION_FAILED: "连接失败,请检查设备是否在线及服务器设置。",
|
||
CONNECTION_TIMEOUT: "等待远端画面超时,请检查远端状态后重试。",
|
||
CONNECTION_LOST: "远程连接已中断。请检查远端状态并重新连接。",
|
||
signaling_timeout: "连接信令服务器超时,请检查本机网络、代理和服务器状态。",
|
||
signaling_disconnected: "信令连接已中断,请检查本机网络、代理和服务器状态。",
|
||
signaling_login_failed: "信令服务器登录失败,请检查服务器配置。",
|
||
tls_certificate_error: "信令服务器证书验证失败,请检查证书和本机时间。",
|
||
cli_identity_not_supported: "信令服务器需要支持 CLI 身份注册,请更新服务器。",
|
||
join_request_failed: "发送设备连接请求失败,请检查信令连接。",
|
||
incorrect_password: "访问密码错误,请重新输入。",
|
||
device_not_found: "设备 ID 无效或已过期,请确认设备 ID。",
|
||
device_offline_or_unavailable: "远端设备暂不可连接,请检查被控端的在线状态。",
|
||
connection_timeout: "等待远端连接超时,请检查被控端和网络状态。",
|
||
connection_failed: "远端连接建立失败,请检查双方网络和中继服务器状态。",
|
||
connection_lost: "远端连接已中断,请检查双方网络状态。",
|
||
remote_disconnected: "远端已断开连接。",
|
||
display_not_available: "所选显示器当前不可用,请检查显示器索引。",
|
||
media_initialization_failed: "本机媒体组件初始化失败,请检查原生 CLI 运行环境。",
|
||
control_event_overflow: "连接事件队列已满,请稍后重新连接。",
|
||
SESSION_REPLACED: "连接已恢复,请使用更新后的会话。",
|
||
SESSION_LIMIT: "活动连接已达到八个,请先断开一个连接。",
|
||
DEVICE_BUSY: "此设备已有活动连接。",
|
||
CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。",
|
||
CREDENTIAL_ROLLBACK_FAILED: "凭据保存中断,请通过本机 setup 重新配置密码。",
|
||
PAIRING_REQUIRED:
|
||
"请在终端运行 Agent 工具结果中的 setup --open-panel 配对命令,再打开面板。",
|
||
PANEL_UNAUTHORIZED: "面板授权已失效,请重新打开。",
|
||
VIEW_UNAUTHORIZED: "此面板已失效,请使用最新打开的面板。",
|
||
CONTROL_DENIED: "当前会话没有控制权限,或已被人工暂停。",
|
||
UNLOCK_PENDING: "请先完成或取消当前解锁请求。",
|
||
UNLOCK_CREDENTIAL_REQUIRED: "请输入系统锁屏密码。",
|
||
UNLOCK_EXPIRED: "解锁请求已失效,请让 Agent 重新观察锁屏并发起请求。",
|
||
UNLOCK_NOT_CONFIRMED: "系统解锁状态尚待确认,请检查远端画面。",
|
||
NATIVE_UNLOCK_UNSUPPORTED: "请更新原生 CLI,以启用锁屏解锁。",
|
||
SAVE_DEVICE_REQUIRED: "请先保存设备,再保存锁屏密码。",
|
||
SAVE_UNLOCK_REQUIRED: "请勾选保存锁屏密码。",
|
||
NATIVE_PACKAGE_MISSING:
|
||
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
|
||
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
|
||
DEVICE_ALIAS_CONFLICT: "这个备注已属于另一台设备,请换一个备注。",
|
||
};
|
||
function text(id: string, value: string) {
|
||
if (element(id).textContent !== value) element(id).textContent = value;
|
||
}
|
||
function platformName(platform?: string) {
|
||
return ({ macos: "macOS", windows: "Windows", linux: "Linux", android: "Android", ios: "iOS" } as Record<string, string>)[platform ?? ""] ?? "远程电脑";
|
||
}
|
||
function resetMetrics() {
|
||
humanInput.setFrame(undefined);
|
||
imageAt = 0;
|
||
frameSize = frameRate = "";
|
||
text("resolution", "—");
|
||
text("fps", "—");
|
||
element<HTMLButtonElement>("focus-view").disabled = true;
|
||
}
|
||
function setFocus(focused: boolean) {
|
||
root.classList.toggle("focus-mode", focused);
|
||
const button = element<HTMLButtonElement>("focus-view");
|
||
const label = focused ? "还原布局" : "放大画面";
|
||
text("focus-label", label);
|
||
if (focused) setDetails(false);
|
||
button.setAttribute("aria-pressed", String(focused));
|
||
button.setAttribute("aria-label", label);
|
||
button.title = label;
|
||
}
|
||
function setDetails(open: boolean) {
|
||
const restoreFocus = !open && root.classList.contains("details-open") &&
|
||
element("sidebar").contains(document.activeElement);
|
||
root.classList.toggle("details-open", open);
|
||
element("details-backdrop").hidden = !open;
|
||
element("details-toggle").setAttribute("aria-expanded", String(open));
|
||
text("details-label", open ? "收起详情" : "会话详情");
|
||
if (open) { setSessionMenu(false, false); element("details-close").focus(); }
|
||
else if (restoreFocus && !root.classList.contains("focus-mode")) element("details-toggle").focus();
|
||
}
|
||
function positionSessionMenu() {
|
||
const menu = element("session-menu");
|
||
const rect = element("more-toggle").getBoundingClientRect();
|
||
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - menu.offsetHeight - 8))}px`;
|
||
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
|
||
}
|
||
function setSessionMenu(open: boolean, restoreFocus = true) {
|
||
const menu = element("session-menu");
|
||
const hadFocus = menu.contains(document.activeElement);
|
||
menu.hidden = !open;
|
||
element("more-toggle").setAttribute("aria-expanded", String(open));
|
||
if (open) {
|
||
setDetails(false);
|
||
positionSessionMenu();
|
||
menu.querySelector<HTMLButtonElement>("button:not(:disabled):not([hidden])")?.focus();
|
||
} else if (restoreFocus && hadFocus) element("more-toggle").focus();
|
||
}
|
||
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
|
||
element("details-close").onclick = element("details-backdrop").onclick = () => setDetails(false);
|
||
element("more-toggle").onclick = () => setSessionMenu(element("session-menu").hidden);
|
||
element("focus-view").onclick = () => {
|
||
setSessionMenu(false);
|
||
setFocus(!root.classList.contains("focus-mode"));
|
||
};
|
||
document.addEventListener("pointerdown", (event) => {
|
||
if (!element("session-menu").hidden && event.target instanceof Node &&
|
||
!element("session-menu").contains(event.target) && !element("more-toggle").contains(event.target))
|
||
setSessionMenu(false, false);
|
||
});
|
||
document.addEventListener("focusin", (event) => {
|
||
if (!element("session-menu").hidden && event.target instanceof Node &&
|
||
!element("session-menu").contains(event.target) && !element("more-toggle").contains(event.target))
|
||
setSessionMenu(false, false);
|
||
});
|
||
window.addEventListener("resize", () => { if (!element("session-menu").hidden) positionSessionMenu(); });
|
||
window.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Escape" || element<HTMLDialogElement>("unlock-dialog").open) return;
|
||
if (!element("session-menu").hidden) { event.preventDefault(); setSessionMenu(false); }
|
||
else if (root.classList.contains("details-open")) { event.preventDefault(); setDetails(false); }
|
||
else if (!ownsInput() && root.classList.contains("focus-mode")) setFocus(false);
|
||
});
|
||
function hidePassword() {
|
||
input("password").type = "password";
|
||
text("password-toggle", "显示");
|
||
element("password-toggle").setAttribute("aria-label", "显示密码");
|
||
element("password-toggle").setAttribute("aria-pressed", "false");
|
||
}
|
||
element("password-toggle").onclick = () => {
|
||
if (input("password").type === "text") return hidePassword();
|
||
input("password").type = "text";
|
||
text("password-toggle", "隐藏");
|
||
element("password-toggle").setAttribute("aria-label", "隐藏密码");
|
||
element("password-toggle").setAttribute("aria-pressed", "true");
|
||
};
|
||
select("mode").onchange = () => {
|
||
text("mode-description", select("mode").value === "control"
|
||
? "Agent 可以操作键鼠,你可以随时暂停。"
|
||
: "Agent 可以查看屏幕,无法操作键鼠。");
|
||
};
|
||
function showError(error: unknown) {
|
||
const code = error instanceof Error ? error.message : "INTERNAL_ERROR";
|
||
element("error").textContent =
|
||
messages[code] ??
|
||
`操作未完成(${/^[A-Z_]+$/.test(code) ? code : "CONNECTION_ERROR"})`;
|
||
element("error").hidden = false;
|
||
}
|
||
function check(result: Data): Data {
|
||
if (result.error) {
|
||
const error = typeof result.error === "string"
|
||
? { code: "CONNECTION_FAILED", reason: result.error } : result.error;
|
||
throw new Error(Object.hasOwn(messages, error.reason)
|
||
? error.reason : error.code ?? "INTERNAL_ERROR");
|
||
}
|
||
return result;
|
||
}
|
||
async function devices() {
|
||
const result = await invoke("devices");
|
||
if (!started && result.defaults) {
|
||
input("server").value = result.defaults.server;
|
||
input("port").value = String(result.defaults.port);
|
||
}
|
||
const chosen = select("device").value || context?.device_alias || "";
|
||
select("device").replaceChildren(new Option("连接新设备", ""));
|
||
savedDevices = result.devices ?? [];
|
||
for (const d of savedDevices)
|
||
select("device").add(
|
||
new Option(`${d.device_alias} · ${d.device_id}`, d.device_alias),
|
||
);
|
||
select("device").value = [...select("device").options].some(
|
||
(o) => o.value === chosen,
|
||
)
|
||
? chosen
|
||
: "";
|
||
deviceChanged();
|
||
}
|
||
function deviceChanged() {
|
||
const saved = !!select("device").value;
|
||
element("new-device").hidden = saved;
|
||
element("new-device-label").hidden = saved;
|
||
input("server").disabled = input("port").disabled = saved;
|
||
input("password").placeholder = saved
|
||
? "留空使用已保存的密码"
|
||
: "输入远端访问密码";
|
||
}
|
||
select("device").onchange = deviceChanged;
|
||
input("save").onchange = () => {
|
||
for (const name of ["remember", "remember-unlock", "authorize"]) {
|
||
input(name).disabled = !input("save").checked;
|
||
if (!input("save").checked) input(name).checked = false;
|
||
}
|
||
savedUnlockChanged();
|
||
};
|
||
function savedUnlockChanged() {
|
||
element("saved-unlock-field").hidden = !input("remember-unlock").checked;
|
||
if (!input("remember-unlock").checked) input("saved-unlock-password").value = "";
|
||
}
|
||
input("remember-unlock").onchange = savedUnlockChanged;
|
||
function hideUnlockToast() {
|
||
clearTimeout(unlockToastTimer);
|
||
unlockToastTimer = undefined;
|
||
element("unlock-toast").hidden = true;
|
||
}
|
||
function openUnlockDialog() {
|
||
const dialog = element<HTMLDialogElement>("unlock-dialog");
|
||
if (status.unlock?.state !== "awaiting_password" || dialog.open) return;
|
||
hideUnlockToast();
|
||
dialog.showModal();
|
||
}
|
||
element("unlock-open").onclick = openUnlockDialog;
|
||
element("unlock-close").onclick = () => element<HTMLDialogElement>("unlock-dialog").close();
|
||
element<HTMLDialogElement>("unlock-dialog").onclose = () => {
|
||
if (element<HTMLDialogElement>("unlock-dialog").open) return;
|
||
input("unlock-password").value = "";
|
||
input("unlock-remember").checked = false;
|
||
};
|
||
element("unlock-toast-close").onclick = hideUnlockToast;
|
||
function renderUnlock(s: Data) {
|
||
const request = s.unlock;
|
||
const pending = request?.state === "awaiting_password";
|
||
const visible = !!request && ["ready", "waiting_frame"].includes(s.state);
|
||
const sessionChanged = s.session_id !== unlockSessionId;
|
||
const changed = sessionChanged || request?.request_id !== unlockRequestId;
|
||
const stateChanged = changed || request?.state !== unlockState;
|
||
const finished = ["submitted", "verified", "cancelled", "failed"].includes(request?.state);
|
||
const dialog = element<HTMLDialogElement>("unlock-dialog");
|
||
if (changed || !pending || !visible) {
|
||
input("unlock-password").value = "";
|
||
input("unlock-remember").checked = false;
|
||
}
|
||
if (changed || finished) unlockError = "";
|
||
if (changed || !visible) hideUnlockToast();
|
||
if (dialog.open && (changed || !visible || finished)) dialog.close();
|
||
unlockRequestId = request?.request_id;
|
||
unlockSessionId = s.session_id;
|
||
unlockState = request?.state;
|
||
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
|
||
if (request?.credential_saved && device) device.unlock_credential_provider = "keyring";
|
||
const provider = request?.credential_saved ? "keyring" : device?.unlock_credential_provider;
|
||
text("session-unlock-credential", provider === "keyring" ? "已保存 · 自动解锁"
|
||
: provider === "environment" ? "环境变量 · 自动解锁" : "待配置");
|
||
element("session-unlock-row").hidden = !visible;
|
||
const states: Record<string, string> = {
|
||
preparing: "准备中", awaiting_password: "等待密码", submitting: "提交中",
|
||
submitted: "已提交", verifying: "验证中", verified: "已解锁", cancelled: "已取消", failed: "提交失败",
|
||
};
|
||
text("session-unlock-state", states[request?.state] ?? "—");
|
||
element("session-unlock-state").dataset.state = request?.state ?? "";
|
||
element("unlock-open").hidden = !visible || !pending;
|
||
element("attention").hidden = !visible || !pending;
|
||
element<HTMLButtonElement>("unlock-open").disabled = unlockBusy;
|
||
element("unlock-form").hidden = !pending;
|
||
input("unlock-password").disabled = unlockBusy;
|
||
input("unlock-remember").disabled = unlockBusy || !device;
|
||
element<HTMLButtonElement>("unlock-submit").disabled = unlockBusy;
|
||
element<HTMLButtonElement>("unlock-cancel").disabled = unlockBusy;
|
||
text("unlock-status", unlockError || (unlockBusy || request?.state === "submitting"
|
||
? "正在提交解锁,请稍候。" : "请确认远端系统密码框已聚焦,然后输入锁屏密码。"));
|
||
if (visible && pending && stateChanged) openUnlockDialog();
|
||
if (visible && finished) {
|
||
text("unlock-toast-title", request.state === "verified" ? "系统已解锁" : request.state === "submitted" ? "解锁请求已提交"
|
||
: request.state === "cancelled" ? "解锁已取消" : "解锁操作未完成");
|
||
let message = ["submitted", "verified"].includes(request.state) ? "Agent 将检查画面并继续任务。" : "";
|
||
if (request.credential_saved) message = "锁屏密码已保存,Agent 将检查画面并继续任务。";
|
||
if (request.error) message = messages[request.error.code] ?? "请检查远端画面后再发起解锁。";
|
||
if (request.persistence_error) message = request.persistence_error === "UNLOCK_NOT_CONFIRMED"
|
||
? "锁屏密码需要在解锁确认后保存,请检查远端画面。" : "密码保存失败,可通过本机 setup 重新配置。";
|
||
text("unlock-toast-message", message);
|
||
element("unlock-toast-message").hidden = !message;
|
||
element("unlock-toast").dataset.state = request.state;
|
||
if (stateChanged && !sessionChanged) {
|
||
element("unlock-toast").hidden = false;
|
||
clearTimeout(unlockToastTimer);
|
||
unlockToastTimer = setTimeout(hideUnlockToast, 6000);
|
||
}
|
||
}
|
||
}
|
||
element<HTMLFormElement>("unlock-form").onsubmit = async (event) => {
|
||
event.preventDefault();
|
||
if (unlockBusy || !unlockRequestId) return;
|
||
const args: Data = { request_id: unlockRequestId, password: input("unlock-password").value,
|
||
remember_password: input("unlock-remember").checked };
|
||
input("unlock-password").value = "";
|
||
unlockError = "";
|
||
unlockBusy = true;
|
||
renderUnlock(status);
|
||
try {
|
||
const result = await invoke("unlock", args);
|
||
renderStatus({ ...status, unlock: result.unlock });
|
||
await devices();
|
||
} catch (error) {
|
||
unlockError = messages[error instanceof Error ? error.message : ""] ?? "解锁请求暂时无法提交,请检查连接状态。";
|
||
}
|
||
finally { args.password = undefined; unlockBusy = false; renderUnlock(status); }
|
||
};
|
||
element("unlock-cancel").onclick = async () => {
|
||
if (!unlockRequestId || unlockBusy) return;
|
||
input("unlock-password").value = "";
|
||
try {
|
||
const result = await invoke("unlock", { request_id: unlockRequestId, cancel: true });
|
||
renderStatus({ ...status, unlock: result.unlock });
|
||
} catch (error) {
|
||
unlockError = messages[error instanceof Error ? error.message : ""] ?? "取消请求暂时无法提交,请检查连接状态。";
|
||
renderUnlock(status);
|
||
}
|
||
};
|
||
function renderStatus(s: Data) {
|
||
const connected = s.state === "ready" || s.state === "waiting_frame";
|
||
const hasSession = connected || s.state === "reconnecting";
|
||
const lost = ["closed", "failed"].includes(s.state);
|
||
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
|
||
const changedSession = s.session_id !== status.session_id;
|
||
status = s;
|
||
element<HTMLButtonElement>("audio-play").disabled = !connected || usePreview;
|
||
input("audio-volume").disabled = !connected || usePreview;
|
||
const audioLabel = audioPlayer.enabled ? "静音" : "播放声音";
|
||
text("audio-label", audioLabel);
|
||
element("audio-play").setAttribute("aria-label", audioLabel);
|
||
element("audio-play").setAttribute("aria-pressed", String(audioPlayer.enabled));
|
||
element("audio-play").title = audioLabel;
|
||
if (!connected || changedSession) audioPlayer.flush();
|
||
humanInput.update(connected && ownsInput() ? s.human_input_epoch : undefined);
|
||
text("take-over-label", ownsInput() ? "交还 Agent" : "人工接管");
|
||
element("take-over").setAttribute("aria-pressed", String(ownsInput()));
|
||
element<HTMLButtonElement>("take-over").disabled = !connected || !s.control_allowed ||
|
||
s.viewer_input_protocol !== "events/1" || controlBusy || (!imageAt && !ownsInput());
|
||
element("take-over").hidden = !s.control_allowed || ownsInput();
|
||
element("return-agent").hidden = !connected || !ownsInput();
|
||
element<HTMLButtonElement>("return-agent").disabled = element<HTMLButtonElement>("take-over").disabled;
|
||
renderUnlock(s);
|
||
const connecting = busy || s.state === "waiting_frame" || s.state === "reconnecting";
|
||
root.dataset.session = String(hasSession);
|
||
root.dataset.connected = String(connected);
|
||
root.dataset.connecting = String(connecting);
|
||
root.dataset.controlling = String(connected && s.control === true && s.control_allowed === true && !s.human_paused);
|
||
element("connection").dataset.state = connected ? "online" : connecting ? "connecting" : lost && !disconnectedByUser ? "offline" : "idle";
|
||
text("connection-label", connected ? "已连接" : connecting ? "正在连接" : lost ? "未连接" : "等待连接");
|
||
element("connect-form").hidden = connected || s.state === "reconnecting";
|
||
element("session-card").hidden = !connected && s.state !== "reconnecting";
|
||
text("sidebar-title", hasSession ? "会话详情" : "授权 Agent 连接");
|
||
text("device-name", hasSession ? s.device_alias || "远程电脑" : "远程画面");
|
||
if (!hasSession || changedSession) { setDetails(false); setSessionMenu(false, false); }
|
||
// Native control state describes permissions/ownership, not task execution.
|
||
const agentState = s.state === "reconnecting" ? "recovering" : !connected ? "idle"
|
||
: s.control_owner === "human" ? "human" : s.human_paused ? "paused"
|
||
: s.unlock?.state === "awaiting_password" ? "unlock"
|
||
: !s.control_allowed ? "observe" : s.control ? "enabled" : "idle";
|
||
const agentLabel = s.state === "reconnecting" ? "正在恢复连接" : !connected ? "等待授权"
|
||
: agentState === "human" ? ownsInput() ? "人工接管中 · Agent 已让出控制" : "其他面板正在人工操作"
|
||
: agentState === "paused" ? "Agent 控制已暂停" : agentState === "unlock" ? "Agent 等待系统解锁"
|
||
: agentState === "observe" ? "Agent 仅查看" : agentState === "enabled" ? "Agent 控制已启用" : "Agent 控制未启用";
|
||
text("agent-state", agentLabel);
|
||
element("agent-state").dataset.state = agentState;
|
||
const platform = platformName(s.platform);
|
||
text("session-info", connected ? `${platform} · 显示器 ${(s.display ?? 0) + 1}` : "等待设备连接");
|
||
text("session-device", s.device_alias || "远程电脑");
|
||
text("session-platform", platform);
|
||
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
|
||
element("session-device-id-row").hidden = !device;
|
||
text("session-device-id", device ? String(device.device_id).replace(/(\d{3})(?=\d)/g, "$1 ") : "");
|
||
text("session-access", s.control_allowed ? "查看与操作" : "仅查看");
|
||
text("session-display", `显示器 ${(s.display ?? 0) + 1}`);
|
||
text("session-agent", agentLabel);
|
||
input("recovery").checked = s.reconnect_enabled === true;
|
||
input("recovery").disabled = !s.session_id;
|
||
element("session-agent").dataset.paused = String(!!s.human_paused);
|
||
element<HTMLButtonElement>("pause").disabled = !connected || !s.control_allowed || controlBusy || s.control_owner === "human";
|
||
element("pause").hidden = !connected || !s.control_allowed || s.control_owner === "human";
|
||
element("pause").classList.toggle("soft", !!s.human_paused);
|
||
const pauseLabel = s.human_paused ? "恢复 Agent" : "暂停 Agent";
|
||
text("pause-label", pauseLabel);
|
||
element("pause").title = s.human_paused ? "恢复 Agent 控制" : "暂停 Agent 控制";
|
||
element<HTMLButtonElement>("disconnect").disabled = !s.session_id || controlBusy || busy;
|
||
element<HTMLButtonElement>("connect").disabled = busy || connected || !invoke;
|
||
element<HTMLFieldSetElement>("connection-fields").disabled = busy;
|
||
element("connect").setAttribute("aria-busy", String(busy));
|
||
text("connect-label", busy ? "正在连接…" : "授权并连接");
|
||
if (!connected) {
|
||
element("frame").hidden = true;
|
||
element("empty").hidden = false;
|
||
element("stale").hidden = true;
|
||
resetMetrics();
|
||
if (root.classList.contains("focus-mode")) setFocus(false);
|
||
} else if (changedSession) {
|
||
resetMetrics();
|
||
element("frame").hidden = true;
|
||
element("empty").hidden = false;
|
||
}
|
||
if (!imageAt && !videoFailed) {
|
||
text("empty-title", connecting || connected ? "正在建立远程连接" : lost ? "会话已结束" : "未连接设备");
|
||
text("empty-description", connecting || connected ? "正在等待远端画面,请稍候。" : lost ? "请重新连接设备。" : "选择设备并输入访问密码。");
|
||
text("video-state", connecting || connected ? "等待画面" : "待连接");
|
||
element("video-state").dataset.state = "idle";
|
||
}
|
||
}
|
||
async function connect() {
|
||
if (!invoke || busy || ["ready", "waiting_frame"].includes(status.state)) return;
|
||
element("error").hidden = true;
|
||
disconnectedByUser = false;
|
||
busy = true;
|
||
renderStatus(status);
|
||
const saved = select("device").value;
|
||
let args: Data = {
|
||
mode: select("mode").value,
|
||
display_index: Number(input("display").value),
|
||
save_device: input("save").checked,
|
||
remember_password: input("remember").checked,
|
||
remember_unlock_password: input("remember-unlock").checked,
|
||
persistent_authorization: input("authorize").checked,
|
||
...(saved
|
||
? { device_alias: saved }
|
||
: {
|
||
device_id: input("device-id").value.replace(/\s/g, ""),
|
||
server: input("server").value,
|
||
port: Number(input("port").value),
|
||
...(input("label").value ? { label: input("label").value } : {}),
|
||
}),
|
||
};
|
||
if (!saved || input("password").value)
|
||
args.password = input("password").value;
|
||
if (input("remember-unlock").checked) args.unlock_password = input("saved-unlock-password").value;
|
||
input("password").value = "";
|
||
input("saved-unlock-password").value = "";
|
||
hidePassword();
|
||
try {
|
||
const result = await invoke("connect", args);
|
||
args = {};
|
||
renderStatus(result);
|
||
if (result.persistence_error)
|
||
showError(new Error(result.persistence_error));
|
||
await devices();
|
||
} catch (error) {
|
||
args = {};
|
||
showError(error);
|
||
} finally {
|
||
busy = false;
|
||
renderStatus(status);
|
||
}
|
||
}
|
||
element("connect").onclick = () => {
|
||
void connect();
|
||
};
|
||
element("connect-form").addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
|
||
event.preventDefault();
|
||
void connect();
|
||
}
|
||
});
|
||
input("recovery").onchange = async () => {
|
||
input("recovery").disabled = true;
|
||
try {
|
||
await invoke("recovery", { enabled: input("recovery").checked });
|
||
renderStatus(await invoke("status"));
|
||
} catch (error) { showError(error); renderStatus(status); }
|
||
};
|
||
element("audio-play").onclick = async () => {
|
||
const enabled = !audioPlayer.enabled;
|
||
element<HTMLButtonElement>("audio-play").disabled = true;
|
||
try {
|
||
await audioPlayer.enable(enabled);
|
||
audioPlayer.volume(Number(input("audio-volume").value));
|
||
await invoke("audio", { enabled });
|
||
} catch (error) { await audioPlayer.enable(false); showError(error); }
|
||
finally { renderStatus(status); }
|
||
};
|
||
input("audio-volume").oninput = () => audioPlayer.volume(Number(input("audio-volume").value));
|
||
element("take-over").onclick = element("return-agent").onclick = async () => {
|
||
if (controlBusy) return;
|
||
setSessionMenu(false);
|
||
controlBusy = true;
|
||
humanInput.release();
|
||
renderStatus(status);
|
||
try {
|
||
await invoke("control", { operation: ownsInput() ? "resume" : "take_over" });
|
||
renderStatus(await invoke("status"));
|
||
} catch (error) { showError(error); }
|
||
finally { controlBusy = false; renderStatus(status); }
|
||
};
|
||
element("pause").onclick = async () => {
|
||
if (controlBusy) return;
|
||
controlBusy = true;
|
||
renderStatus(status);
|
||
try {
|
||
await invoke("control", {
|
||
operation: status.human_paused ? "resume" : "pause",
|
||
});
|
||
renderStatus(await invoke("status"));
|
||
} catch (e) {
|
||
showError(e);
|
||
} finally {
|
||
controlBusy = false;
|
||
renderStatus(status);
|
||
}
|
||
};
|
||
element("disconnect").onclick = async () => {
|
||
if (controlBusy) return;
|
||
setSessionMenu(false);
|
||
controlBusy = true;
|
||
disconnectedByUser = true;
|
||
renderStatus(status);
|
||
try {
|
||
await closePreview();
|
||
await invoke("control", { operation: "disconnect" });
|
||
element("error").hidden = true;
|
||
resetMetrics();
|
||
renderStatus(await invoke("status"));
|
||
} catch (e) {
|
||
disconnectedByUser = false;
|
||
showError(e);
|
||
} finally {
|
||
controlBusy = false;
|
||
renderStatus(status);
|
||
}
|
||
};
|
||
async function closePreview() {
|
||
audioPlayer.flush();
|
||
++videoEpoch;
|
||
player?.stop();
|
||
player = undefined;
|
||
videoSession = undefined;
|
||
videoGeneration = undefined;
|
||
videoStarting = videoFailed = false;
|
||
element("video-retry").hidden = true;
|
||
element("video-browser").hidden = true;
|
||
element("panel-status").hidden = true;
|
||
}
|
||
async function startPreview() {
|
||
if (player || videoStarting || videoFailed || document.hidden) return;
|
||
const epoch = ++videoEpoch;
|
||
videoSession = status.session_id;
|
||
videoGeneration = status.generation;
|
||
videoStarting = true;
|
||
const failed = (error: Error) => {
|
||
if (epoch !== videoEpoch) return;
|
||
if (CROSSDESK_APPS && !usePreview &&
|
||
["VIDEO_POLICY_BLOCKED", "VIDEO_UNAVAILABLE", "VIDEO_UNSUPPORTED"].includes(error.message)) {
|
||
player?.stop();
|
||
player = undefined;
|
||
usePreview = true;
|
||
videoStarting = false;
|
||
void startPreview();
|
||
return;
|
||
}
|
||
videoFailed = true;
|
||
showError(error);
|
||
element("video-retry").hidden = false;
|
||
element("video-browser").hidden = !CROSSDESK_APPS;
|
||
element("stale").hidden = !imageAt;
|
||
text("video-state", "画面不可用");
|
||
element("video-state").dataset.state = "stale";
|
||
if (!imageAt) {
|
||
text("empty-title", "暂时无法显示画面");
|
||
text("empty-description", "会话仍然保留。请尝试重新加载画面。");
|
||
}
|
||
};
|
||
const presented = (info: FrameInfo) => {
|
||
if (epoch !== videoEpoch) return;
|
||
humanInput.setFrame({ ...info, ...(info.sourceSize ?? {}) });
|
||
imageAt = Date.now();
|
||
element("frame").hidden = false;
|
||
element("empty").hidden = true;
|
||
element("stale").hidden = true;
|
||
const size = `${info.width} × ${info.height}`;
|
||
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
|
||
if (size !== frameSize) { frameSize = size; text("resolution", size); }
|
||
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
|
||
element<HTMLButtonElement>("focus-view").disabled = false;
|
||
text("video-state", usePreview ? "预览" : "实时");
|
||
element("video-state").dataset.state = "live";
|
||
};
|
||
try {
|
||
if (usePreview) {
|
||
audioPlayer.flush();
|
||
element<HTMLButtonElement>("audio-play").disabled = true;
|
||
input("audio-volume").disabled = true;
|
||
element("error").hidden = true;
|
||
text("panel-status", "实时视频不可用,已切换为内嵌预览:最高每秒 1 帧,不含声音。需要流畅画面时可使用浏览器面板。");
|
||
element("panel-status").hidden = false;
|
||
const preview = new PreviewPlayer(element<HTMLCanvasElement>("frame"), status.generation,
|
||
(args) => invoke("preview", args), presented, failed);
|
||
player = preview;
|
||
preview.start();
|
||
return;
|
||
}
|
||
const grant = await invoke("video_open");
|
||
if (epoch !== videoEpoch || document.hidden) return;
|
||
const video = new VideoPlayer(element<HTMLCanvasElement>("frame"), presented,
|
||
failed, element<HTMLCanvasElement>("agent-cursor"), (bytes) => audioPlayer.accept(bytes));
|
||
player = video;
|
||
video.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
|
||
} catch (error) {
|
||
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
|
||
} finally {
|
||
if (epoch === videoEpoch) videoStarting = false;
|
||
}
|
||
}
|
||
element("video-retry").onclick = async () => {
|
||
await closePreview();
|
||
usePreview = false;
|
||
element("error").hidden = true;
|
||
await startPreview();
|
||
};
|
||
element("video-browser").onclick = async () => {
|
||
try {
|
||
const result = await app!.callServerTool({name: "crossdesk_open_browser", arguments: {view_id: context!.view_id}});
|
||
if (result.isError) throw new Error("VIDEO_UNAVAILABLE");
|
||
started = false;
|
||
await closePreview();
|
||
element("error").hidden = true;
|
||
for (const name of ["connect", "pause", "disconnect"])
|
||
element<HTMLButtonElement>(name).disabled = true;
|
||
element("panel-status").hidden = false;
|
||
element("panel-status").textContent = "浏览器观看面板已打开。";
|
||
} catch (error) { showError(error); }
|
||
};
|
||
async function poll() {
|
||
if (polling || !invoke || !started || document.hidden || busy || controlBusy) return;
|
||
polling = true;
|
||
try {
|
||
renderStatus(await invoke("status"));
|
||
if (videoSession && (videoSession !== status.session_id ||
|
||
videoGeneration !== status.generation)) {
|
||
if (videoFailed) element("error").hidden = true;
|
||
await closePreview();
|
||
}
|
||
if (status.state !== "ready" || document.hidden) {
|
||
await closePreview();
|
||
return;
|
||
}
|
||
await startPreview();
|
||
const stale = !!imageAt && Date.now() - imageAt > 2000;
|
||
element("stale").hidden = !stale;
|
||
if (stale) {
|
||
text("video-state", "等待画面");
|
||
element("video-state").dataset.state = "stale";
|
||
text("fps", "—");
|
||
frameRate = "";
|
||
}
|
||
} catch (error) { showError(error); }
|
||
finally { polling = false; }
|
||
}
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.hidden) void closePreview();
|
||
else void poll();
|
||
});
|
||
window.addEventListener("pagehide", () => {
|
||
element<HTMLDialogElement>("unlock-dialog").close();
|
||
hideUnlockToast();
|
||
input("unlock-password").value = "";
|
||
input("saved-unlock-password").value = "";
|
||
void closePreview();
|
||
});
|
||
setInterval(() => {
|
||
void poll();
|
||
}, 1000);
|
||
async function ready(next: Data) {
|
||
if (
|
||
started &&
|
||
context?.view_id === next.view_id &&
|
||
context?.view_generation === next.view_generation
|
||
)
|
||
return;
|
||
if (started) await closePreview();
|
||
context = next;
|
||
element("panel-status").hidden = true;
|
||
started = false;
|
||
await invoke("ready");
|
||
await devices();
|
||
started = true;
|
||
element<HTMLButtonElement>("connect").disabled = false;
|
||
await poll();
|
||
}
|
||
async function start() {
|
||
if (CROSSDESK_APPS) {
|
||
const { App } = await import("@modelcontextprotocol/ext-apps");
|
||
app = new App({ name: "CrossDesk MCP", version: "0.1.0" }, {});
|
||
const names: Record<string, string> = {
|
||
ready: "crossdesk_ui_ready",
|
||
devices: "crossdesk_ui_list_devices",
|
||
status: "crossdesk_ui_get_session",
|
||
connect: "crossdesk_ui_connect",
|
||
video_open: "crossdesk_video_open",
|
||
preview: "crossdesk_ui_preview",
|
||
control: "crossdesk_viewer_control",
|
||
unlock: "crossdesk_ui_unlock",
|
||
recovery: "crossdesk_ui_recovery",
|
||
input: "crossdesk_ui_input",
|
||
audio: "crossdesk_ui_audio",
|
||
};
|
||
let connected = false,
|
||
initial: Data | undefined;
|
||
invoke = async (name, args = {}) => {
|
||
const result = await app!.callServerTool({
|
||
name: names[name]!,
|
||
arguments: {
|
||
...args,
|
||
view_id: context!.view_id,
|
||
view_generation: context!.view_generation,
|
||
ui_token: context!.ui_token,
|
||
},
|
||
});
|
||
const data =
|
||
result.structuredContent ??
|
||
JSON.parse((result.content?.[0] as Data)?.text ?? "{}");
|
||
check(data);
|
||
return name === "preview" ? (result._meta?.["crossdesk/preview"] as Data) ?? data : data;
|
||
};
|
||
app.ontoolresult = (result) => {
|
||
const meta = result._meta?.["crossdesk/ui"] as Data | undefined;
|
||
if (!meta) return;
|
||
initial = { ...(result.structuredContent ?? {}), ...meta };
|
||
if (connected) void ready(initial).catch(showError);
|
||
};
|
||
app.onteardown = async () => {
|
||
await closePreview();
|
||
return {};
|
||
};
|
||
await app.connect();
|
||
connected = true;
|
||
if (initial) await ready(initial);
|
||
} else {
|
||
let csrf = "";
|
||
const request = async (path: string, body?: Data) =>
|
||
check(
|
||
await (
|
||
await fetch("/api/" + path, {
|
||
method: body ? "POST" : "GET",
|
||
credentials: "same-origin",
|
||
headers: body
|
||
? { "Content-Type": "application/json", "X-CrossDesk-CSRF": csrf }
|
||
: {},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
cache: "no-store",
|
||
})
|
||
).json(),
|
||
);
|
||
const bootstrap = new URLSearchParams(location.hash.slice(1)).get(
|
||
"bootstrap",
|
||
);
|
||
history.replaceState(null, "", location.pathname);
|
||
const result = bootstrap
|
||
? await request("bootstrap", { token: bootstrap })
|
||
: await request("context");
|
||
csrf = result.csrf;
|
||
invoke = (name, args = {}) =>
|
||
request(name, { ...args, view_id: context!.view_id });
|
||
const previous = sessionStorage.getItem("crossdesk-view");
|
||
const selected = bootstrap ? result : result.views.find((view: Data) => view.view_id === previous) ?? result.views.at(-1);
|
||
await ready(selected);
|
||
sessionStorage.setItem("crossdesk-view", selected.view_id);
|
||
}
|
||
}
|
||
void start().catch(showError);
|