mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 19:07:33 +08:00
[feat] add persistent remote unlock credentials and local password dialogs
This commit is contained in:
@@ -160,6 +160,7 @@ Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见
|
||||
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;按 `view_id` 可等待连接,`wait_ms` 范围 0–25000 |
|
||||
| `crossdesk_screenshot` | 返回 PNG image content 和观测元数据 |
|
||||
| `crossdesk_action` | 结构化输入,可附带动作之后的截图 |
|
||||
| `crossdesk_unlock` | 使用独立的系统锁屏凭据解锁,或在本地面板请求输入密码 |
|
||||
| `crossdesk_set_control` | 在已授权范围内启停 Agent 输入 |
|
||||
| `crossdesk_select_display` | 切换显示器,使旧观测失效 |
|
||||
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
|
||||
@@ -178,6 +179,18 @@ Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见
|
||||
|
||||
`operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 仅说明本地发送完成,远端没有执行 ACK。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明操作执行成功。
|
||||
|
||||
### 锁屏解锁与密码保存
|
||||
|
||||
Agent 识别到远端系统锁屏后,先聚焦画面中的系统密码框并重新截图,再调用 `crossdesk_unlock`,传入 `session_id`、`operation_id` 和截图的 `observation_id`。默认 `use_saved: true` 会读取该设备独立保存的锁屏凭据。缺少凭据时,浏览器或 Apps 面板通过悬浮弹窗显示隐藏输入框,终端观看器显示隐藏输入提示。弹窗可关闭,并通过左侧连接信息中的“输入密码”重新打开。提交结果显示为六秒后收起的悬浮提示,连接信息持续展示解锁状态和锁屏凭据配置状态。
|
||||
|
||||
用户可在解锁输入框勾选“保存密码并允许以后自动解锁”。连接表单的“保存与授权”和本机 `crossdesk-mcp setup` 也提供锁屏密码配置。持久保存要求设备已在本地设备簿中,系统凭据库使用独立账户保存访问密码和锁屏密码。设备列表返回 `unlock_credential_provider` 摘要,配置文件保存 `unlock_credential` 引用。环境变量方式使用 `unlock_credential: "environment"` 和 `unlock_environment_variable`,旧设备配置继续有效。
|
||||
|
||||
等待输入时,Agent 使用 `crossdesk_get_session`,提供 `session_id` 或 `view_id`、`unlock_request_id` 和 `wait_ms: 25000`。`wait_timed_out` 后可继续等待,等待取消仅结束当前查询。解锁请求有效期为五分钟,等待期间保留实时画面,常规 Agent 输入和显示器切换会返回 `UNLOCK_PENDING`。用户取消、人工暂停、连接结束或画面代际变化会终止请求。
|
||||
|
||||
提交前重新获取同一显示器代际的截图,原生端执行选择密码框内容、文本输入和回车。成功发送返回 `unlock.state: "submitted"`,Agent 随后用新截图确认桌面是否已解锁,再继续原任务。保存选项在输入提交后写入凭据库,密码正确性仍需检查远端画面。密码被拒绝时,使用新请求并设置 `use_saved: false` 让用户更正,结果不确定时先观察画面。相同 `operation_id` 与参数查询原请求结果。
|
||||
|
||||
锁屏密码通过经过认证的本地面板或终端 IPC 进入原生输入通道。模型侧工具参数和结果只包含请求状态,原生操作缓存仅保存解锁请求标识及结果。解锁遵守现有控制授权和人工暂停状态,依赖远端文本输入能力。`off` 模式可使用预先配置的锁屏凭据,缺少凭据时返回 `UNLOCK_CREDENTIAL_REQUIRED`。原生 CLI 通过 `unlock_protocol: "password/1"` 声明支持,更新源码后需重建 CLI 和 MCP 并重新启动服务。
|
||||
|
||||
## 实时视频与按需截图
|
||||
|
||||
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
|
||||
|
||||
@@ -255,7 +255,7 @@ export class BrowserPanel {
|
||||
if (typeof body.view_id !== "string" || !client.views.has(body.view_id))
|
||||
throw new Failure("VIEW_UNAUTHORIZED");
|
||||
const name = req.url?.match(
|
||||
/^\/api\/(ready|devices|status|connect|video_open|control)$/,
|
||||
/^\/api\/(ready|devices|status|connect|video_open|control|unlock)$/,
|
||||
)?.[1];
|
||||
if (!name) throw new Failure("UNKNOWN_ACTION");
|
||||
const { view_id, ...args } = body;
|
||||
|
||||
@@ -46,6 +46,8 @@ export const alias = z
|
||||
.max(80)
|
||||
.regex(/^[\p{L}\p{N} _.-]+$/u);
|
||||
export const mode = z.enum(["observe", "control"]);
|
||||
export const unlockPassword = z.string().min(1).max(256)
|
||||
.refine((value) => Buffer.byteLength(value) <= 256 && !/[\0\r\n\t]/.test(value));
|
||||
export const panel = z.enum(["auto", "apps", "browser", "terminal", "off"]);
|
||||
export type Panel = z.infer<typeof panel>;
|
||||
export const videoOptions = z.strictObject({
|
||||
@@ -138,5 +140,7 @@ export const uiConnect = z.strictObject({
|
||||
port: z.int().min(1).max(65535).optional(),
|
||||
save_device: z.boolean().default(false),
|
||||
remember_password: z.boolean().default(false),
|
||||
unlock_password: unlockPassword.optional(),
|
||||
remember_unlock_password: z.boolean().default(false),
|
||||
persistent_authorization: z.boolean().default(false),
|
||||
});
|
||||
|
||||
+20
-1
@@ -19,6 +19,7 @@ import {
|
||||
failure,
|
||||
uiConnect,
|
||||
videoOptions,
|
||||
unlockPassword,
|
||||
type Panel,
|
||||
} from "./common.js";
|
||||
|
||||
@@ -98,9 +99,27 @@ async function setup(store: DeviceStore, view?: string) {
|
||||
...(environment
|
||||
? { credential: "environment", environment_variable: environment }
|
||||
: {}),
|
||||
...(previous?.unlock_credential ? {
|
||||
unlock_credential: previous.unlock_credential,
|
||||
unlock_environment_variable: previous.unlock_environment_variable,
|
||||
} : {}),
|
||||
};
|
||||
await store.save(d, password);
|
||||
let unlockSecret: string | undefined;
|
||||
if ((await question("配置锁屏密码,供 Agent 后续自动解锁?[y/N]:")).toLowerCase() === "y") {
|
||||
const variable = await question("锁屏密码环境变量名称(留空使用系统凭据库):");
|
||||
if (variable) {
|
||||
d.unlock_credential = "environment";
|
||||
d.unlock_environment_variable = variable;
|
||||
} else {
|
||||
hidden = true;
|
||||
process.stdout.write("系统锁屏密码(隐藏输入):");
|
||||
try { unlockSecret = parse(unlockPassword, await rl.question("")); }
|
||||
finally { hidden = false; process.stdout.write("\n"); }
|
||||
}
|
||||
}
|
||||
await store.save(d, password, unlockSecret);
|
||||
password = undefined;
|
||||
unlockSecret = undefined;
|
||||
process.stdout.write(
|
||||
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
|
||||
);
|
||||
|
||||
@@ -92,6 +92,7 @@ type Pending = {
|
||||
action: boolean;
|
||||
};
|
||||
export class NativeClient {
|
||||
unlockSupported = false;
|
||||
private child: ChildProcess;
|
||||
private pending = new Map<number, Pending>();
|
||||
private sequence = 0;
|
||||
@@ -148,6 +149,7 @@ export class NativeClient {
|
||||
5000,
|
||||
signal,
|
||||
);
|
||||
this.unlockSupported = hello.unlock_protocol === "password/1";
|
||||
if (hello.protocol !== "crossdesk-automation/1" ||
|
||||
hello.terminal_protocol !== "ansi/1" ||
|
||||
hello.video_transport !== "loopback-websocket/1" ||
|
||||
@@ -188,7 +190,7 @@ export class NativeClient {
|
||||
request_id: id,
|
||||
}) + "\n",
|
||||
);
|
||||
reject(new Failure(code, method === "action"));
|
||||
reject(new Failure(code, method === "action" || method === "unlock"));
|
||||
};
|
||||
const abort = () => cancel("CANCELLED");
|
||||
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
|
||||
@@ -196,7 +198,7 @@ export class NativeClient {
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject,
|
||||
action: method === "action",
|
||||
action: method === "action" || method === "unlock",
|
||||
clean: () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
|
||||
+33
-4
@@ -38,6 +38,7 @@ const uiNames: Record<UIAction, string> = {
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
control: "crossdesk_viewer_control",
|
||||
unlock: "crossdesk_ui_unlock",
|
||||
};
|
||||
function output(value: Data, meta?: Data): CallToolResult {
|
||||
const data = { ...value };
|
||||
@@ -68,7 +69,7 @@ export function createServer(
|
||||
{
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
instructions:
|
||||
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. If the remote system is locked, focus its visible password field, take a fresh screenshot and call crossdesk_unlock. A saved system password is used automatically; otherwise the local panel asks the user. Wait with crossdesk_get_session(unlock_request_id, wait_ms: 25000), then verify a fresh screenshot before continuing the original task. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||
},
|
||||
);
|
||||
type Definition = {
|
||||
@@ -123,7 +124,7 @@ export function createServer(
|
||||
inputSchema: schema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
destructiveHint: ["crossdesk_action", "crossdesk_unlock"].includes(d.name),
|
||||
openWorldHint: true,
|
||||
},
|
||||
...(d.ui || d.app
|
||||
@@ -236,11 +237,15 @@ export function createServer(
|
||||
session_id: id.optional(),
|
||||
view_id: id.optional(),
|
||||
wait_ms: z.int().min(0).max(25000).optional(),
|
||||
unlock_request_id: id.optional(),
|
||||
}),
|
||||
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With view_id, wait for connection (default 25000 ms; 0 returns immediately). On wait_timed_out, repeat while the remote task is pending. On ready, continue without asking the user to send a message. Waiting never connects or grants control. session_id returns immediately and requires wait_ms to be 0 or omitted.",
|
||||
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With unlock_request_id, wait for that unlock request to finish (default 25000 ms). Otherwise view_id waits for connection and session_id returns immediately. On wait_timed_out, repeat while the task is pending. An unlock state of submitted requires a fresh screenshot to verify success. Waiting never connects or grants control.",
|
||||
async (p, ctx) => {
|
||||
if (!!p.session_id === !!p.view_id)
|
||||
throw new Failure("SELECT_ONE_SESSION");
|
||||
if (p.unlock_request_id) return output(await sessions.waitForUnlock(
|
||||
p.session_id, p.view_id, p.unlock_request_id, p.wait_ms ?? 25000, ctx.mcpReq.signal,
|
||||
));
|
||||
if (p.session_id && p.wait_ms) throw new Failure("INVALID_ARGUMENT");
|
||||
return output(p.view_id
|
||||
? await sessions.waitForConnection(p.view_id, p.wait_ms ?? 25000, ctx.mcpReq.signal)
|
||||
@@ -267,6 +272,30 @@ export function createServer(
|
||||
"Submit one structured action against a recent observation. Reuse operation_id only to retrieve that same operation, never replay uncertain effects with a new ID. Human pause prevents control. A post-action screenshot is not an execution acknowledgement.",
|
||||
async (p, ctx) => output(await sessions.action(p, ctx.mcpReq.signal)),
|
||||
);
|
||||
add(
|
||||
"crossdesk_unlock",
|
||||
z.strictObject({
|
||||
session_id: id,
|
||||
operation_id: id,
|
||||
observation_id: id,
|
||||
use_saved: z.boolean().default(true),
|
||||
}),
|
||||
"Unlock the remote system after visually identifying its lock screen, focusing the password field and obtaining a fresh screenshot. Uses its separately saved system password, or asks through the local panel. Never supply passwords in chat or tool arguments. Repeat the same operation_id and parameters only to query that request. Wait with crossdesk_get_session and the returned unlock.request_id, then inspect a fresh screenshot. If the password was rejected, use_saved: false asks the user to replace it. Never automatically retry uncertain or rejected input.",
|
||||
async (p, ctx) => {
|
||||
let v = [...sessions.views.values()].find((view) => view.session === p.session_id);
|
||||
const created = !v;
|
||||
if (!v) v = sessions.create(sessions.choose(supported(ctx)));
|
||||
let result: Data;
|
||||
try { result = await sessions.requestUnlock(p, ctx.mcpReq.signal); }
|
||||
catch (error) { if (created) sessions.views.delete(v.id); throw error; }
|
||||
let panel = sessions.describe(v);
|
||||
try {
|
||||
if (result.unlock.state === "awaiting_password" && !v.ready) panel = await openPanel(v);
|
||||
} catch (error) { panel.panel_error = failure(error); }
|
||||
return viewResult(v, { ...panel, ...result, next_step: result.unlock.next_step });
|
||||
},
|
||||
{ ui: true },
|
||||
);
|
||||
add(
|
||||
"crossdesk_set_control",
|
||||
z.strictObject({ session_id: id, enabled: z.boolean() }),
|
||||
@@ -367,7 +396,7 @@ export function createServer(
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: !!d.readOnly,
|
||||
destructiveHint: d.name === "crossdesk_action",
|
||||
destructiveHint: ["crossdesk_action", "crossdesk_unlock"].includes(d.name),
|
||||
openWorldHint: true,
|
||||
},
|
||||
...((d.ui || d.app) && apps(ctx)
|
||||
|
||||
+181
-4
@@ -7,6 +7,7 @@ import {
|
||||
equal,
|
||||
parse,
|
||||
uiConnect,
|
||||
unlockPassword,
|
||||
type Data,
|
||||
type Panel,
|
||||
type VideoOptions,
|
||||
@@ -57,6 +58,23 @@ interface Session {
|
||||
connecting: boolean;
|
||||
statusRequest?: Promise<Data>;
|
||||
closing?: Promise<void>;
|
||||
unlock?: UnlockRequest;
|
||||
unlocks: Map<string, UnlockRequest>;
|
||||
}
|
||||
interface UnlockRequest {
|
||||
id: string;
|
||||
operation: string;
|
||||
observation: string;
|
||||
useSaved: boolean;
|
||||
state: "preparing" | "awaiting_password" | "submitting" | "submitted" | "failed" | "cancelled";
|
||||
expires: number;
|
||||
generation?: number;
|
||||
geometry?: number;
|
||||
error?: Data;
|
||||
result?: Data;
|
||||
persistenceError?: string;
|
||||
saved?: boolean;
|
||||
controller: AbortController;
|
||||
}
|
||||
export class Sessions {
|
||||
readonly views = new Map<string, View>();
|
||||
@@ -92,7 +110,7 @@ export class Sessions {
|
||||
created: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
ready: false,
|
||||
alias,
|
||||
alias: alias ?? this.current?.device.alias,
|
||||
session: this.current?.id,
|
||||
};
|
||||
this.views.set(view.id, view);
|
||||
@@ -170,10 +188,12 @@ export class Sessions {
|
||||
if (["closed", "failed"].includes(s.status.state)) {
|
||||
await this.dispose(s);
|
||||
}
|
||||
this.checkUnlock(s);
|
||||
return {
|
||||
...s.status,
|
||||
session_id: s.id,
|
||||
device_alias: s.device.alias,
|
||||
...(s.unlock ? { unlock: this.unlockStatus(s.unlock) } : {}),
|
||||
...(view ? this.describe(view) : {}),
|
||||
state: s.status.state,
|
||||
...(s.status.state === "ready" ? {
|
||||
@@ -181,6 +201,7 @@ export class Sessions {
|
||||
} : ["closed", "failed", "disconnected"].includes(s.status.state) ? {
|
||||
next_step: "The connection ended. Stop waiting and do not automatically reconnect or replay actions.",
|
||||
} : {}),
|
||||
...(s.unlock && this.unlockPending(s.unlock) ? { next_step: this.unlockStatus(s.unlock).next_step } : {}),
|
||||
};
|
||||
}
|
||||
private async waitStatus(request: Promise<Data>, signal?: AbortSignal) {
|
||||
@@ -197,6 +218,7 @@ export class Sessions {
|
||||
}
|
||||
}
|
||||
private dispose(s: Session): Promise<void> {
|
||||
this.cancelUnlock(s);
|
||||
return s.closing ??= s.native.close().finally(() => s.release());
|
||||
}
|
||||
async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||
@@ -267,8 +289,12 @@ export class Sessions {
|
||||
};
|
||||
if (p.device_alias && (p.server || p.port || p.label))
|
||||
throw new Failure("INVALID_ARGUMENT");
|
||||
if ((p.remember_password || p.persistent_authorization) && !p.save_device)
|
||||
if ((p.remember_password || p.remember_unlock_password || p.persistent_authorization) && !p.save_device)
|
||||
throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||
if (p.remember_unlock_password && !p.unlock_password)
|
||||
throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
if (p.unlock_password && !p.remember_unlock_password)
|
||||
throw new Failure("SAVE_UNLOCK_REQUIRED");
|
||||
let password = p.password ?? (await this.store.password(d));
|
||||
p.password = undefined;
|
||||
// Persist only after successful authentication, avoiding poisoned saved passwords.
|
||||
@@ -282,14 +308,17 @@ export class Sessions {
|
||||
);
|
||||
if (p.save_device) {
|
||||
d.authorization = p.persistent_authorization ? p.mode : undefined;
|
||||
delete d.unlock_credential;
|
||||
delete d.unlock_environment_variable;
|
||||
try {
|
||||
await this.store.save(d, p.remember_password ? password : undefined);
|
||||
await this.store.save(d, p.remember_password ? password : undefined, p.unlock_password);
|
||||
} catch (error) {
|
||||
result.persistence_error =
|
||||
error instanceof Failure ? error.code : "CONFIG_WRITE_FAILED";
|
||||
}
|
||||
}
|
||||
password = "";
|
||||
p.unlock_password = undefined;
|
||||
return result;
|
||||
}
|
||||
private async connect(
|
||||
@@ -342,6 +371,7 @@ export class Sessions {
|
||||
status: {},
|
||||
release,
|
||||
connecting: true,
|
||||
unlocks: new Map(),
|
||||
};
|
||||
this.current = s;
|
||||
if (view) {
|
||||
@@ -408,6 +438,8 @@ export class Sessions {
|
||||
async action(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const { session_id, observe_after = true, ...args } = p;
|
||||
const s = this.session(session_id);
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
const result = await s.native.request("action", args, 5000, signal);
|
||||
if (observe_after && result.status === "submitted") {
|
||||
try {
|
||||
@@ -428,10 +460,14 @@ export class Sessions {
|
||||
return result;
|
||||
}
|
||||
async control(id: string, enabled: boolean) {
|
||||
if (!enabled) this.cancelUnlock(this.session(id));
|
||||
return this.session(id).native.request("control", { enabled });
|
||||
}
|
||||
async display(id: string, index: number) {
|
||||
return this.session(id).native.request("display", { index });
|
||||
const s = this.session(id);
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
return s.native.request("display", { index });
|
||||
}
|
||||
async disconnect(id: string) {
|
||||
const s = this.session(id);
|
||||
@@ -449,10 +485,151 @@ export class Sessions {
|
||||
async viewerControl(v: View, operation: string) {
|
||||
if (!v.session) throw new Failure("NOT_READY");
|
||||
if (operation === "disconnect") return this.disconnect(v.session);
|
||||
if (operation === "pause") this.cancelUnlock(this.session(v.session));
|
||||
return this.session(v.session).native.request("viewer_control", {
|
||||
operation,
|
||||
});
|
||||
}
|
||||
private unlockPending(request: UnlockRequest) {
|
||||
return ["preparing", "awaiting_password", "submitting"].includes(request.state);
|
||||
}
|
||||
private unlockStatus(request: UnlockRequest): Data {
|
||||
return {
|
||||
request_id: request.id,
|
||||
state: request.state,
|
||||
expires_at: request.expires,
|
||||
...(request.error ? { error: request.error } : {}),
|
||||
...(request.result ? { result: request.result } : {}),
|
||||
...(request.persistenceError ? { persistence_error: request.persistenceError } : {}),
|
||||
...(request.saved ? { credential_saved: true } : {}),
|
||||
next_step: this.unlockPending(request)
|
||||
? "The user enters the system lock-screen password in the local panel. Never request it in chat. Wait with crossdesk_get_session and unlock_request_id."
|
||||
: request.state === "submitted"
|
||||
? "Take a fresh screenshot to verify that the desktop is unlocked, then continue the pending task. If the password was rejected, request user entry with use_saved: false; never automatically replay an unlock."
|
||||
: "Unlock stopped. Inspect the error and current screen before starting another request. Never automatically replay an uncertain submission.",
|
||||
};
|
||||
}
|
||||
private cancelUnlock(s: Session, code = "CANCELLED") {
|
||||
const request = s.unlock;
|
||||
if (!request || !this.unlockPending(request)) return;
|
||||
request.error = failure(new Failure(code, request.state === "submitting"));
|
||||
request.state = "cancelled";
|
||||
request.controller.abort();
|
||||
}
|
||||
private checkUnlock(s: Session) {
|
||||
const request = s.unlock;
|
||||
if (!request || !this.unlockPending(request)) return;
|
||||
if (Date.now() > request.expires) this.cancelUnlock(s, "UNLOCK_EXPIRED");
|
||||
else if (s.closing || !s.status.control || s.status.human_paused ||
|
||||
(request.generation !== undefined && (request.generation !== s.status.generation ||
|
||||
request.geometry !== s.status.geometry_epoch))) this.cancelUnlock(s, "CONTROL_DENIED");
|
||||
}
|
||||
async requestUnlock(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const s = this.session(p.session_id);
|
||||
if (!s.native.unlockSupported) throw new Failure("NATIVE_UNLOCK_UNSUPPORTED");
|
||||
const previous = s.unlocks.get(p.operation_id);
|
||||
if (previous) {
|
||||
if (previous.observation !== p.observation_id || previous.useSaved !== p.use_saved)
|
||||
throw new Failure("OPERATION_ID_CONFLICT");
|
||||
this.checkUnlock(s);
|
||||
return { session_id: s.id, unlock: this.unlockStatus(previous) };
|
||||
}
|
||||
this.checkUnlock(s);
|
||||
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
|
||||
const request: UnlockRequest = {
|
||||
id: p.operation_id, operation: token(), observation: p.observation_id, useSaved: p.use_saved,
|
||||
state: "preparing", expires: Date.now() + 300000, controller: new AbortController(),
|
||||
};
|
||||
s.unlock = request;
|
||||
s.unlocks.set(request.id, request);
|
||||
if (s.unlocks.size > 128) s.unlocks.delete(s.unlocks.keys().next().value!);
|
||||
try {
|
||||
const prepared = await s.native.request("unlock_prepare", { observation_id: p.observation_id }, 5000, signal);
|
||||
request.generation = prepared.generation;
|
||||
request.geometry = prepared.geometry_epoch;
|
||||
let password: string | undefined;
|
||||
if (p.use_saved) {
|
||||
try {
|
||||
const d = await this.store.device(s.device.alias);
|
||||
if (d.device_id !== s.device.device_id || d.server !== s.device.server || d.port !== s.device.port)
|
||||
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||
password = await this.store.password(d, "unlock");
|
||||
} catch (error) {
|
||||
if (!["DEVICE_NOT_FOUND", "UNLOCK_CREDENTIAL_REQUIRED", "CREDENTIAL_STORE_UNAVAILABLE"].includes(failure(error).code)) throw error;
|
||||
}
|
||||
}
|
||||
if (!this.unlockPending(request) || signal?.aborted) throw new Failure("CANCELLED");
|
||||
request.state = "awaiting_password";
|
||||
if (password !== undefined) {
|
||||
try { await this.submitUnlock(s, request, password, false, signal); }
|
||||
finally { password = undefined; }
|
||||
} else if (this.policy === "off") throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
} catch (error) {
|
||||
if (request.state !== "cancelled") {
|
||||
request.state = "failed";
|
||||
request.error = failure(error);
|
||||
}
|
||||
}
|
||||
return { session_id: s.id, unlock: this.unlockStatus(request) };
|
||||
}
|
||||
private async submitUnlock(s: Session, request: UnlockRequest, password: string, remember: boolean, signal?: AbortSignal) {
|
||||
parse(unlockPassword, password);
|
||||
await this.status(s.id, undefined, signal);
|
||||
if (s.unlock !== request || request.state !== "awaiting_password") throw new Failure("UNLOCK_EXPIRED");
|
||||
request.state = "submitting";
|
||||
const combined = signal ? AbortSignal.any([signal, request.controller.signal]) : request.controller.signal;
|
||||
try {
|
||||
// User input may take longer than an observation's lifetime. Re-observe the
|
||||
// same display generation before sending the secret to its focused field.
|
||||
const observation = await this.screenshot({ session_id: s.id }, combined);
|
||||
const metadata = observation.metadata;
|
||||
if (metadata.generation !== request.generation || metadata.geometry_epoch !== request.geometry)
|
||||
throw new Failure("STALE_OBSERVATION");
|
||||
const result = await s.native.request("unlock", {
|
||||
operation_id: request.operation,
|
||||
observation_id: metadata.observation_id,
|
||||
password,
|
||||
}, 5000, combined);
|
||||
request.result = result;
|
||||
if (request.controller.signal.aborted) return;
|
||||
request.state = result.status === "submitted" ? "submitted" : "failed";
|
||||
if (remember && request.state === "submitted") {
|
||||
try { await this.store.saveUnlock(s.device, password); request.saved = true; }
|
||||
catch (error) { request.persistenceError = failure(error).code; }
|
||||
}
|
||||
} catch (error) {
|
||||
if (!request.controller.signal.aborted) {
|
||||
request.state = "failed";
|
||||
request.error = failure(error);
|
||||
}
|
||||
} finally { password = ""; }
|
||||
}
|
||||
async unlockUI(v: View, p: Data, signal?: AbortSignal): Promise<Data> {
|
||||
const s = this.session(v.session!);
|
||||
const request = s.unlock;
|
||||
if (!request || request.id !== p.request_id) throw new Failure("UNLOCK_NOT_FOUND");
|
||||
if (p.cancel) this.cancelUnlock(s);
|
||||
else if (request.state === "awaiting_password") {
|
||||
const password = p.password;
|
||||
p.password = undefined;
|
||||
await this.submitUnlock(s, request, password, p.remember_password, signal);
|
||||
}
|
||||
return { unlock: this.unlockStatus(request) };
|
||||
}
|
||||
async waitForUnlock(sessionId: string | undefined, viewId: string | undefined, requestId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||
const deadline = Date.now() + waitMs;
|
||||
while (true) {
|
||||
const result = await this.status(sessionId, viewId, signal);
|
||||
const s = result.session_id && this.current?.id === result.session_id ? this.current : undefined;
|
||||
if (!s || ["closed", "failed"].includes(result.state)) return result;
|
||||
const request = s.unlocks.get(requestId);
|
||||
if (!request) throw new Failure("UNLOCK_NOT_FOUND");
|
||||
result.unlock = this.unlockStatus(request);
|
||||
if (!this.unlockPending(request)) return result;
|
||||
if (Date.now() >= deadline) return { ...result, wait_timed_out: true };
|
||||
await delay(Math.min(200, deadline - Date.now()), undefined, { signal });
|
||||
}
|
||||
}
|
||||
async terminalFrame(v: View, args: Data, signal?: AbortSignal) {
|
||||
if (v.panel !== "terminal" || !v.session || this.connecting)
|
||||
throw new Failure("NOT_READY");
|
||||
|
||||
+56
-18
@@ -20,6 +20,7 @@ import {
|
||||
Failure,
|
||||
parse,
|
||||
token,
|
||||
unlockPassword,
|
||||
type Data,
|
||||
} from "./common.js";
|
||||
|
||||
@@ -34,6 +35,8 @@ const deviceSchema = z.strictObject({
|
||||
.string()
|
||||
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
|
||||
.optional(),
|
||||
unlock_credential: z.enum(["keyring", "environment"]).optional(),
|
||||
unlock_environment_variable: z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/).optional(),
|
||||
});
|
||||
export type Device = z.infer<typeof deviceSchema>;
|
||||
const configSchema = z.strictObject({
|
||||
@@ -76,6 +79,7 @@ export class DeviceStore {
|
||||
port: d.port,
|
||||
authorization: d.authorization ?? "none",
|
||||
credential_provider: d.credential ?? "none",
|
||||
unlock_credential_provider: d.unlock_credential ?? "none",
|
||||
}));
|
||||
}
|
||||
async device(name: string): Promise<Device> {
|
||||
@@ -83,39 +87,45 @@ export class DeviceStore {
|
||||
if (!d) throw new Failure("DEVICE_NOT_FOUND");
|
||||
return d;
|
||||
}
|
||||
private account(d: Device) {
|
||||
private account(d: Device, purpose: "connection" | "unlock") {
|
||||
const identity = [this.directory, d.server, d.port, d.device_id];
|
||||
if (purpose === "unlock") identity.push("unlock");
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([this.directory, d.server, d.port, d.device_id]))
|
||||
.update(JSON.stringify(identity))
|
||||
.digest("hex");
|
||||
}
|
||||
private async entry(d: Device) {
|
||||
private async entry(d: Device, purpose: "connection" | "unlock" = "connection") {
|
||||
try {
|
||||
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||
return new AsyncEntry("crossdesk-mcp", this.account(d), {
|
||||
return new AsyncEntry("crossdesk-mcp", this.account(d, purpose), {
|
||||
linux: { store: "secret-service" },
|
||||
});
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
async password(d: Device): Promise<string> {
|
||||
if (d.credential === "environment") {
|
||||
async password(d: Device, purpose: "connection" | "unlock" = "connection"): Promise<string> {
|
||||
const provider = purpose === "unlock" ? d.unlock_credential : d.credential;
|
||||
const variable = purpose === "unlock" ? d.unlock_environment_variable : d.environment_variable;
|
||||
if (provider === "environment") {
|
||||
const secret =
|
||||
d.environment_variable && process.env[d.environment_variable];
|
||||
variable && process.env[variable];
|
||||
if (secret !== undefined) return secret;
|
||||
} else if (d.credential === "keyring") {
|
||||
} else if (provider === "keyring") {
|
||||
try {
|
||||
const secret = await (await this.entry(d)).getPassword();
|
||||
const secret = await (await this.entry(d, purpose)).getPassword();
|
||||
if (secret !== null) return secret;
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
throw new Failure("AUTH_REQUIRED");
|
||||
throw new Failure(purpose === "unlock" ? "UNLOCK_CREDENTIAL_REQUIRED" : "AUTH_REQUIRED");
|
||||
}
|
||||
async save(d: Device, password?: string) {
|
||||
async save(d: Device, password?: string, unlockSecret?: string, update: "device" | "unlock" = "device") {
|
||||
parse(deviceSchema, d);
|
||||
if (unlockSecret !== undefined) parse(unlockPassword, unlockSecret);
|
||||
const lock = await this.lock("config");
|
||||
const restore: Array<() => Promise<unknown>> = [];
|
||||
try {
|
||||
const config = await this.config();
|
||||
const previous = config.devices.find((item) => item.alias === d.alias);
|
||||
@@ -126,20 +136,38 @@ export class DeviceStore {
|
||||
previous.port !== d.port)
|
||||
)
|
||||
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||
if (update === "unlock") {
|
||||
if (!previous) throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||
d = { ...previous };
|
||||
}
|
||||
if (password !== undefined) {
|
||||
try {
|
||||
await (await this.entry(d)).setPassword(password);
|
||||
d.credential = "keyring";
|
||||
delete d.environment_variable;
|
||||
} catch {
|
||||
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||
}
|
||||
d.credential = "keyring";
|
||||
delete d.environment_variable;
|
||||
}
|
||||
// Keep separately configured system credentials when updating connection settings.
|
||||
if (previous && !d.unlock_credential) {
|
||||
d.unlock_credential = previous.unlock_credential;
|
||||
d.unlock_environment_variable = previous.unlock_environment_variable;
|
||||
}
|
||||
if (unlockSecret !== undefined) {
|
||||
d.unlock_credential = "keyring";
|
||||
delete d.unlock_environment_variable;
|
||||
}
|
||||
config.devices = [
|
||||
...config.devices.filter((item) => item.alias !== d.alias),
|
||||
d,
|
||||
];
|
||||
parse(configSchema, config);
|
||||
const saveSecret = async (purpose: "connection" | "unlock", secret: string) => {
|
||||
try {
|
||||
const entry = await this.entry(d, purpose);
|
||||
const previous = await entry.getPassword();
|
||||
restore.push(() => previous === null ? entry.deletePassword() : entry.setPassword(previous));
|
||||
await entry.setPassword(secret);
|
||||
} catch { throw new Failure("CREDENTIAL_STORE_UNAVAILABLE"); }
|
||||
};
|
||||
if (password !== undefined) await saveSecret("connection", password);
|
||||
if (unlockSecret !== undefined) await saveSecret("unlock", unlockSecret);
|
||||
const temporary = `${this.file}.${token()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
|
||||
@@ -150,10 +178,20 @@ export class DeviceStore {
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
let restored = true;
|
||||
for (const undo of restore.reverse()) {
|
||||
try { await undo(); } catch { restored = false; }
|
||||
}
|
||||
if (!restored) throw new Failure("CREDENTIAL_ROLLBACK_FAILED");
|
||||
throw error;
|
||||
} finally {
|
||||
await lock();
|
||||
}
|
||||
}
|
||||
async saveUnlock(d: Device, password: string) {
|
||||
await this.save(d, undefined, password, "unlock");
|
||||
}
|
||||
async lock(name: string): Promise<() => Promise<void>> {
|
||||
const path = join(
|
||||
this.directory,
|
||||
|
||||
@@ -41,6 +41,34 @@ async function connect(client: TerminalClient) {
|
||||
} finally { rl.close(); }
|
||||
}
|
||||
|
||||
async function unlock(client: TerminalClient, requestId: string, device: string) {
|
||||
let hidden = false;
|
||||
const output = new Writable({ write(chunk, _encoding, done) {
|
||||
if (!hidden) process.stdout.write(chunk);
|
||||
done();
|
||||
} });
|
||||
const rl = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
||||
const controller = new AbortController();
|
||||
rl.once("SIGINT", () => controller.abort());
|
||||
const args: Data = { request_id: requestId };
|
||||
try {
|
||||
process.stdout.write("Agent 请求解锁远程系统。请确认远端系统密码框已聚焦。\n系统锁屏密码(隐藏输入,留空取消):");
|
||||
hidden = true;
|
||||
try { args.password = await rl.question("", { signal: controller.signal }); }
|
||||
finally { hidden = false; process.stdout.write("\n"); }
|
||||
if (!args.password) args.cancel = true;
|
||||
else {
|
||||
const { devices } = await client.call("devices");
|
||||
if (devices.some((entry: Data) => entry.device_alias === device))
|
||||
args.remember_password = (await rl.question("保存到系统凭据库并允许以后自动解锁?[y/N]:", { signal: controller.signal })).trim().toLowerCase() === "y";
|
||||
}
|
||||
return await client.call("unlock", args);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) throw error;
|
||||
return await client.call("unlock", { request_id: requestId, cancel: true });
|
||||
} finally { args.password = undefined; rl.close(); }
|
||||
}
|
||||
|
||||
export async function terminalView(directory: string, view: string, cellAspect = 0.5) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.TERM === "dumb")
|
||||
throw new Failure("VIEW_REQUIRES_TERMINAL");
|
||||
@@ -72,7 +100,7 @@ export async function terminalView(directory: string, view: string, cellAspect =
|
||||
process.stdin.resume();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
|
||||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||
let statusAt = 0, size = "";
|
||||
let statusAt = 0, size = "", promptedUnlock = "";
|
||||
const colors = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") ? 24
|
||||
: /256color/i.test(process.env.TERM ?? "") ? 256 : 8;
|
||||
while (!stopped) {
|
||||
@@ -82,6 +110,25 @@ export async function terminalView(directory: string, view: string, cellAspect =
|
||||
statusAt = Date.now();
|
||||
if (["closed", "failed", "disconnected"].includes(status.state)) break;
|
||||
}
|
||||
if (status.unlock?.state === "awaiting_password" && status.unlock.request_id !== promptedUnlock && !controls) {
|
||||
promptedUnlock = status.unlock.request_id;
|
||||
process.stdin.off("keypress", keypress);
|
||||
process.stdin.setRawMode(false);
|
||||
await write("\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l");
|
||||
try {
|
||||
const result = await unlock(client, promptedUnlock, status.device_alias);
|
||||
message = result.unlock?.error?.code ?? result.unlock?.persistence_error ?? "";
|
||||
} catch (error) { message = failure(error).code; }
|
||||
finally {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on("keypress", keypress);
|
||||
process.stdin.resume();
|
||||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||
statusAt = 0;
|
||||
size = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Leave a spare column so terminals with unusual wrap behavior stay safe.
|
||||
const columns = Math.min(160, Math.max(1, (process.stdout.columns || 80) - 1));
|
||||
const rows = Math.min(60, Math.max(1, (process.stdout.rows || 24) - 2));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { parse, uiConnect, Failure, type Data } from "./common.js";
|
||||
import { parse, uiConnect, unlockPassword, id, Failure, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
|
||||
export const uiSchemas = {
|
||||
@@ -8,6 +8,12 @@ export const uiSchemas = {
|
||||
status: z.strictObject({}),
|
||||
connect: uiConnect,
|
||||
video_open: z.strictObject({}),
|
||||
unlock: z.strictObject({
|
||||
request_id: id,
|
||||
password: unlockPassword.optional(),
|
||||
remember_password: z.boolean().default(false),
|
||||
cancel: z.boolean().default(false),
|
||||
}),
|
||||
control: z.strictObject({
|
||||
operation: z.enum(["pause", "resume", "disconnect"]),
|
||||
}),
|
||||
@@ -40,6 +46,9 @@ export async function uiAction(
|
||||
return sessions.connectUI(view, p, signal);
|
||||
case "control":
|
||||
return sessions.viewerControl(view, p.operation);
|
||||
case "unlock":
|
||||
if (!p.cancel && !p.password) throw new Failure("UNLOCK_CREDENTIAL_REQUIRED");
|
||||
return sessions.unlockUI(view, p, signal);
|
||||
default:
|
||||
throw new Failure("UNKNOWN_ACTION");
|
||||
}
|
||||
|
||||
@@ -70,6 +70,22 @@ button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-c
|
||||
#session-agent[data-paused="true"] { color: #956e2d; }
|
||||
.session-note { margin-top: 18px; }
|
||||
.workspace { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; overflow: auto; padding: 16px; background: #f6f7f8; }
|
||||
.session-unlock-details { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; }
|
||||
#session-unlock-state[data-state="awaiting_password"] { color: #956e2d; }
|
||||
#session-unlock-state[data-state="failed"] { color: #9a4e47; }
|
||||
.unlock-dialog { width: min(420px, calc(100% - 32px)); max-height: calc(100% - 32px); margin: auto; padding: 22px; color: inherit; background: #fff; border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 18px 64px #17243733; }
|
||||
.unlock-dialog::backdrop { background: #19212e4d; }
|
||||
.unlock-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.unlock-dialog-header h2 { font-size: 16px; }
|
||||
.unlock-dialog-header button, .unlock-toast > button { width: 28px; min-height: 28px; padding: 0; flex: none; border: 0; color: var(--muted); font-size: 20px; line-height: 1; }
|
||||
.unlock-dialog p { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.unlock-buttons { display: flex; gap: 8px; }
|
||||
.unlock-buttons .primary { margin: 0; width: auto; min-width: 80px; }
|
||||
.unlock-toast { position: fixed; top: 70px; right: 20px; z-index: 10; display: flex; align-items: flex-start; gap: 12px; width: min(360px, calc(100% - 32px)); padding: 14px 14px 14px 16px; border: 1px solid var(--line); border-radius: 8px; background: #fff; box-shadow: 0 6px 24px #17243726; }
|
||||
.unlock-toast > div { min-width: 0; flex: 1; }
|
||||
.unlock-toast strong { display: block; padding-top: 4px; font-size: 13px; font-weight: 600; }
|
||||
.unlock-toast p { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.unlock-toast[data-state="failed"] { border-color: #d6b4ae; }
|
||||
.viewer-card { display: flex; flex-direction: column; flex: 1; min-height: 160px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
|
||||
.viewer-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 9px 12px; flex: none; }
|
||||
.viewer-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||||
|
||||
+150
-4
@@ -30,8 +30,10 @@ root.innerHTML = `
|
||||
<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">
|
||||
@@ -43,7 +45,7 @@ root.innerHTML = `
|
||||
</div>
|
||||
<div id="session-card" hidden>
|
||||
<div class="device-card"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></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></dl>
|
||||
<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><button id="unlock-open" type="button" hidden>输入密码</button></dd></div></dl>
|
||||
<p class="session-note">关闭页面不会断开连接。</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -65,7 +67,17 @@ root.innerHTML = `
|
||||
<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>`;
|
||||
</main>
|
||||
<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);
|
||||
@@ -76,6 +88,12 @@ 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 | undefined;
|
||||
@@ -115,11 +133,18 @@ const messages: Record<string, string> = {
|
||||
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 重新观察锁屏并发起请求。",
|
||||
NATIVE_UNLOCK_UNSUPPORTED: "请更新原生 CLI,以启用锁屏解锁。",
|
||||
SAVE_DEVICE_REQUIRED: "请先保存设备,再保存锁屏密码。",
|
||||
SAVE_UNLOCK_REQUIRED: "请勾选保存锁屏密码。",
|
||||
NATIVE_PACKAGE_MISSING:
|
||||
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
|
||||
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
|
||||
@@ -156,7 +181,7 @@ function setDetails(open: boolean) {
|
||||
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
|
||||
element("focus-view").onclick = () => setFocus(!root.classList.contains("focus-mode"));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && root.classList.contains("focus-mode")) setFocus(false);
|
||||
if (event.key === "Escape" && !element<HTMLDialogElement>("unlock-dialog").open && root.classList.contains("focus-mode")) setFocus(false);
|
||||
});
|
||||
function hidePassword() {
|
||||
input("password").type = "password";
|
||||
@@ -223,10 +248,122 @@ function deviceChanged() {
|
||||
}
|
||||
select("device").onchange = deviceChanged;
|
||||
input("save").onchange = () => {
|
||||
for (const name of ["remember", "authorize"]) {
|
||||
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", "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: "已提交", cancelled: "已取消", failed: "提交失败",
|
||||
};
|
||||
text("session-unlock-state", states[request?.state] ?? "—");
|
||||
element("session-unlock-state").dataset.state = request?.state ?? "";
|
||||
element("unlock-open").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 === "submitted" ? "解锁请求已提交"
|
||||
: request.state === "cancelled" ? "解锁已取消" : "解锁操作未完成");
|
||||
let message = request.state === "submitted" ? "Agent 将检查画面并继续任务。" : "";
|
||||
if (request.credential_saved) message = "锁屏密码已保存,Agent 将检查画面并继续任务。";
|
||||
if (request.error) message = messages[request.error.code] ?? "请检查远端画面后再发起解锁。";
|
||||
if (request.persistence_error) message = "密码保存失败,可通过本机 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";
|
||||
@@ -234,6 +371,7 @@ function renderStatus(s: Data) {
|
||||
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
|
||||
const changedSession = s.session_id !== status.session_id;
|
||||
status = s;
|
||||
renderUnlock(s);
|
||||
const connecting = busy || s.state === "waiting_frame";
|
||||
root.dataset.connected = String(connected);
|
||||
root.dataset.connecting = String(connecting);
|
||||
@@ -296,6 +434,7 @@ async function connect() {
|
||||
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 }
|
||||
@@ -308,7 +447,9 @@ async function connect() {
|
||||
};
|
||||
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);
|
||||
@@ -472,6 +613,10 @@ document.addEventListener("visibilitychange", () => {
|
||||
else void poll();
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
element<HTMLDialogElement>("unlock-dialog").close();
|
||||
hideUnlockToast();
|
||||
input("unlock-password").value = "";
|
||||
input("saved-unlock-password").value = "";
|
||||
void closePreview();
|
||||
});
|
||||
setInterval(() => {
|
||||
@@ -505,6 +650,7 @@ async function start() {
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
control: "crossdesk_viewer_control",
|
||||
unlock: "crossdesk_ui_unlock",
|
||||
};
|
||||
let connected = false,
|
||||
initial: Data | undefined;
|
||||
|
||||
Reference in New Issue
Block a user