[feat] expose system controls and verify remote unlock completion

This commit is contained in:
dijunkun
2026-11-06 20:46:11 +08:00
parent 6bfcd5cf3d
commit dd4baf2fc4
16 changed files with 222 additions and 15 deletions
+15
View File
@@ -788,6 +788,21 @@ private:
} else if (method == "action") {
StartAction(j, id);
return false;
} else if (method == "system") {
Fields(j, {"id", "method", "command", "operation_id"});
const auto command = String(j, "command", 16);
if (command != "status" && command != "lock" && command != "send_sas")
throw std::invalid_argument("INVALID_ARGUMENT");
const bool write = command != "status";
if (write && (!allowed_ || paused_ || !session_.ControlEnabled()))
throw std::invalid_argument("CONTROL_DENIED");
if (session_.Status()["capabilities"].value("system", json{}) != true)
throw std::invalid_argument("SYSTEM_UNSUPPORTED");
const auto operation = write ? String(j, "operation_id") : std::string{};
if (write && PreviousRemote(j, id, operation)) return false;
if (remote_jobs_.size() >= 16) throw std::invalid_argument("BUSY");
TrackRemote(j, id, session_.RequestRemote("system", {{"operation", command}}), write, operation);
return false;
} else if (method == "files") {
Fields(j, {"id", "method", "operation", "operation_id", "path", "paths",
"destination", "request_id", "offset"});
+6
View File
@@ -151,3 +151,9 @@ Updated desktop hosts answer the versioned `automation` capability handshake. Wh
`files` supports `list`, `list_result`, `list_cancel`, `upload`, `download`, `status`, and `cancel`. Listing requests return a numeric `request_id`. Transfer requests require an `operation_id`, absolute `paths`, and an existing absolute `destination` directory. Progress and listing pages use `offset` and return `next_offset` with at most 256 entries.
The desktop and CLI share `FileBrowser` and the existing file transfer engine. Preparation and completion messages carry a transfer identifier to bind incoming files to the requested destination. A cancelled transfer cleans unfinished staging files. The capability handshake advertises `files` on compatible hosts. Queries and cancellation of existing tasks remain available during human pause.
## System control
`system` accepts `command: status | lock | send_sas`. Commands that change system state require an `operation_id` and control permission. Status queries are available during human pause. The host executes system operations through its application-thread queue, checks connection authorization again, and expires queued operations after two seconds. Windows secure attention uses the existing service integration.
`lock_state` is `locked`, `unlocked`, or `unknown`. macOS queries the current console session, Windows uses its session lock state, and Linux queries the current logind session's `LockedHint`. The MCP unlock workflow verifies this state on compatible hosts and returns `verified` when the system reports an unlocked session.
+2
View File
@@ -12,6 +12,8 @@ namespace crossdesk {
// Called on the application thread. True means the OS accepted the request,
// not that the asynchronous transition to the lock screen has finished.
bool LockScreen();
enum class ScreenLockState { unknown, unlocked, locked };
ScreenLockState QueryScreenLock();
} // namespace crossdesk
@@ -2609,6 +2609,7 @@ void GuiApplication::Tick() {
}
}
clipboard_.ApplyPendingRemoteText();
ProcessAutomationRequests();
#ifndef __OHOS__
std::string activation_token;
bool activation_requested =
@@ -1,5 +1,6 @@
#include "runtime/gui_runtime.h"
#include <stream_names.h>
#include "screen_lock.h"
namespace crossdesk {
void GuiRuntime::HandleAutomation(const std::string& remote_id,
@@ -21,7 +22,11 @@ void GuiRuntime::HandleAutomation(const std::string& remote_id,
try {
const auto method = request.value("method", std::string{});
if (method == "capabilities") {
reply(true, {{"input_ack", true}, {"clipboard", true}, {"files", true}});
json capabilities{{"input_ack", true}, {"clipboard", true}, {"files", true}};
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
capabilities["system"] = true;
#endif
reply(true, std::move(capabilities));
} else if (method == "input") {
RemoteAction action{};
if (!request.contains("arguments") ||
@@ -37,6 +42,46 @@ void GuiRuntime::HandleAutomation(const std::string& remote_id,
if (result == RemoteInputDispatcher::Result::rejected ||
result == RemoteInputDispatcher::Result::overflow)
reply(false, "INPUT_REJECTED");
} else if (method == "system") {
const auto operation = request.at("arguments").at("operation").get<std::string>();
if (operation != "status" && operation != "lock" && operation != "send_sas") {
reply(false, "INVALID_ARGUMENT");
return;
}
std::lock_guard lock(automation_mutex_);
if (automation_closing_ || automation_tasks_.size() >= 32) { reply(false, "BUSY"); return; }
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
automation_tasks_.push_back([this, source, remote_id, operation, reply, deadline] {
if (std::chrono::steady_clock::now() > deadline) { reply(false, "REQUEST_EXPIRED"); return; }
if (ControllerEvents(remote_id) != source || !IsAuthorizedController(remote_id)) return;
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (operation == "status") {
const auto state = QueryScreenLock();
json result{{"lock_state", state == ScreenLockState::locked ? "locked"
: state == ScreenLockState::unlocked ? "unlocked" : "unknown"}};
#ifdef _WIN32
result["interactive_stage"] = WindowsInputStage();
result["service_available"] = local_service_available_.load();
result["send_sas_available"] = local_service_available_.load();
#else
result["send_sas_available"] = false;
#endif
reply(true, std::move(result));
} else if (operation == "lock") {
reply(true, {{"accepted", LockScreen()}});
} else {
#ifdef _WIN32
if (!local_service_available_.load()) { reply(false, "SERVICE_UNAVAILABLE"); return; }
pending_windows_service_sas_.store(true);
reply(true, {{"accepted", true}, {"state", "queued"}});
#else
reply(false, "UNSUPPORTED_OPERATION");
#endif
}
#else
reply(false, "UNSUPPORTED_OPERATION");
#endif
});
} else {
reply(false, "UNSUPPORTED_OPERATION");
}
@@ -44,4 +89,15 @@ void GuiRuntime::HandleAutomation(const std::string& remote_id,
reply(false, "INVALID_ARGUMENT");
}
}
void GuiRuntime::ProcessAutomationRequests() {
std::function<void()> task;
{
std::lock_guard lock(automation_mutex_);
if (automation_tasks_.empty() || automation_closing_) return;
task = std::move(automation_tasks_.front());
automation_tasks_.pop_front();
}
try { task(); } catch (...) {}
}
} // namespace crossdesk
@@ -65,6 +65,11 @@ GuiRuntime::GuiRuntime()
}) {}
GuiRuntime::~GuiRuntime() {
{
std::lock_guard lock(automation_mutex_);
automation_closing_ = true;
automation_tasks_.clear();
}
remote_input_.Stop();
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
@@ -104,6 +104,7 @@ class GuiRuntime : protected gui_detail::GuiState {
void HandleServerControllerDisconnected(const std::string& remote_id,
const char* reason);
void HandleWindowsServiceIntegration();
void ProcessAutomationRequests();
void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id);
@@ -153,6 +154,9 @@ class GuiRuntime : protected gui_detail::GuiState {
void SendRemoteInput(const RemoteAction& action);
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
void HandleAutomation(const std::string& remote_id, const nlohmann::json& request);
std::mutex automation_mutex_;
std::deque<std::function<void()>> automation_tasks_;
bool automation_closing_ = false;
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
std::string gallery_group_control_id_;
@@ -7,11 +7,54 @@
#include "screen_lock.h"
#include <dbus/dbus.h>
#include <unistd.h>
#include "rd_log.h"
namespace crossdesk {
ScreenLockState QueryScreenLock() {
dbus_threads_init_default();
DBusConnection* connection = dbus_bus_get_private(DBUS_BUS_SYSTEM, nullptr);
if (!connection) return ScreenLockState::unknown;
dbus_connection_set_exit_on_disconnect(connection, false);
ScreenLockState state = ScreenLockState::unknown;
DBusMessage* query = dbus_message_new_method_call("org.freedesktop.login1",
"/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetSessionByPID");
if (query) {
dbus_uint32_t pid = getpid();
dbus_message_append_args(query, DBUS_TYPE_UINT32, &pid, DBUS_TYPE_INVALID);
DBusMessage* session = dbus_connection_send_with_reply_and_block(connection, query, 500, nullptr);
dbus_message_unref(query);
const char* path = nullptr;
if (session && dbus_message_get_args(session, nullptr, DBUS_TYPE_OBJECT_PATH, &path, DBUS_TYPE_INVALID)) {
query = dbus_message_new_method_call("org.freedesktop.login1", path,
"org.freedesktop.DBus.Properties", "Get");
const char* interface = "org.freedesktop.login1.Session";
const char* property = "LockedHint";
if (query) {
dbus_message_append_args(query, DBUS_TYPE_STRING, &interface, DBUS_TYPE_STRING, &property, DBUS_TYPE_INVALID);
DBusMessage* reply = dbus_connection_send_with_reply_and_block(connection, query, 500, nullptr);
dbus_message_unref(query);
DBusMessageIter outer, value;
if (reply && dbus_message_iter_init(reply, &outer) && dbus_message_iter_get_arg_type(&outer) == DBUS_TYPE_VARIANT) {
dbus_message_iter_recurse(&outer, &value);
if (dbus_message_iter_get_arg_type(&value) == DBUS_TYPE_BOOLEAN) {
dbus_bool_t locked = false;
dbus_message_iter_get_basic(&value, &locked);
state = locked ? ScreenLockState::locked : ScreenLockState::unlocked;
}
}
if (reply) dbus_message_unref(reply);
}
}
if (session) dbus_message_unref(session);
}
dbus_connection_close(connection);
dbus_connection_unref(connection);
return state;
}
bool LockScreen() {
dbus_threads_init_default();
DBusError error;
@@ -7,11 +7,23 @@
#include "screen_lock.h"
#include <dlfcn.h>
#include <ApplicationServices/ApplicationServices.h>
#include "rd_log.h"
namespace crossdesk {
ScreenLockState QueryScreenLock() {
const auto session = CGSessionCopyCurrentDictionary();
if (!session) return ScreenLockState::unknown;
const auto console = CFDictionaryGetValue(session, kCGSessionOnConsoleKey);
const auto locked = CFDictionaryGetValue(session, CFSTR("CGSSessionScreenIsLocked"));
const auto state = locked == kCFBooleanTrue ? ScreenLockState::locked
: console == kCFBooleanTrue ? ScreenLockState::unlocked : ScreenLockState::unknown;
CFRelease(session);
return state;
}
bool LockScreen() {
// macOS has no public immediate-lock API. Resolve login.framework at
// runtime so an OS without this private entry point fails gracefully.
@@ -5,6 +5,7 @@
*/
#include "screen_lock.h"
#include "service/interactive_desktop.h"
#include <windows.h>
@@ -12,6 +13,13 @@
namespace crossdesk {
ScreenLockState QueryScreenLock() {
std::string reason;
if (IsCurrentSessionUnlocked(&reason)) return ScreenLockState::unlocked;
return reason.rfind("session_not_unlocked", 0) == 0 ? ScreenLockState::locked
: ScreenLockState::unknown;
}
bool LockScreen() {
if (LockWorkStation()) return true;
LOG_WARN("LockWorkStation failed: {}", GetLastError());
+1 -1
View File
@@ -88,7 +88,7 @@ function setup_targets()
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/system_info.cpp",
"apps/desktop/src/platform/windows/screen_lock.cpp")
add_syslinks("Advapi32", {public = true})
add_syslinks("Advapi32", "Wtsapi32", {public = true})
elseif is_os("macosx") then
add_files("apps/desktop/src/platform/macos/system_info.cpp",
"apps/desktop/src/platform/macos/screen_lock.cpp")
+6
View File
@@ -262,3 +262,9 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts
`crossdesk_files_transfer` 支持上传和下载文件、文件夹,源路径为绝对路径,目标目录需要已存在。传输使用 `operation_id` 去重,通过 `crossdesk_files_status` 查询每个文件的字节进度和终态,通过 `crossdesk_files_cancel` 取消并清理未完成文件。每个连接同时处理一个传输任务,多文件按现有传输引擎排队。已完成任务保留四份历史记录,状态中的文件清单分页返回。目标同名文件按现有冲突命名规则另存。
文件操作需要更新后的被控端声明 `capabilities.files`。下载请求绑定目标目录及传输标识,接收器接受已为该请求预留的文件。人工暂停后仍可查询或取消已有任务。
### 系统状态与安全桌面
`crossdesk_system_status` 查询系统锁定状态。Windows 同时报告服务可用性和交互桌面阶段。`crossdesk_system_command` 支持 `lock`,Windows 可通过已运行的服务执行 `send_sas`。命令需要控制授权和独立的 `operation_id`,相同请求查询原结果。系统接受命令后,通过状态及画面观察确认实际界面。
支持系统状态的被控端会在密码提交后接受最长八秒的解锁验证。确认解锁后返回 `unlock.state: verified`,并保存用户选择记住的密码。系统状态无法确认时保留 `submitted` 和验证信息。已经解锁的桌面返回 `already_unlocked`。旧被控端沿用截图确认流程。
+1
View File
@@ -175,6 +175,7 @@ export class NativeClient {
const id = ++this.sequence;
const sideEffect = method === "action" || method === "unlock" ||
(method === "clipboard" && args.operation === "set") ||
(method === "system" && args.command !== "status") ||
(method === "files" && ["upload", "download", "cancel"].includes(args.operation));
const line = JSON.stringify({ ...args, id, method }) + "\n";
if (Buffer.byteLength(line) > 65536)
+10 -2
View File
@@ -125,7 +125,7 @@ export function createServer(
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: ["crossdesk_action", "crossdesk_unlock", "crossdesk_clipboard_set",
"crossdesk_files_transfer", "crossdesk_files_cancel"].includes(d.name),
"crossdesk_files_transfer", "crossdesk_files_cancel", "crossdesk_system_command"].includes(d.name),
openWorldHint: true,
},
...(d.ui || d.app
@@ -339,6 +339,14 @@ export function createServer(
add("crossdesk_files_cancel", z.strictObject({ session_id: id, operation_id: id }),
"Cancel a transfer and remove its unfinished staging files. Completed files remain available.",
async (p, ctx) => output(await sessions.files({ ...p, operation: "cancel" }, ctx.mcpReq.signal)));
add("crossdesk_system_status", z.strictObject({ session_id: id }),
"Query the remote operating system's lock state and available secure-desktop service. unknown means the platform could not determine the state.",
async (p, ctx) => output(await sessions.system({ ...p, command: "status" }, ctx.mcpReq.signal)),
{ readOnly: true });
add("crossdesk_system_command", z.strictObject({ session_id: id, operation_id: id,
command: z.enum(["lock", "send_sas"]) }),
"Lock the remote session or request Windows secure attention through its service. Requires control authorization. Reuse operation_id to query the same command. Check system status and a fresh screenshot after acceptance.",
async (p, ctx) => output(await sessions.system(p, ctx.mcpReq.signal)));
add("crossdesk_clipboard_set", z.strictObject({ session_id: id, operation_id: id,
text: z.string().max(16384).refine((text) => Buffer.byteLength(text) <= 16384 && !text.includes("\0")) }),
"Set or clear the remote text clipboard. Reuse the same operation_id to query the original result. Returns applied after the remote clipboard update.",
@@ -438,7 +446,7 @@ export function createServer(
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: ["crossdesk_action", "crossdesk_unlock", "crossdesk_clipboard_set",
"crossdesk_files_transfer", "crossdesk_files_cancel"].includes(d.name),
"crossdesk_files_transfer", "crossdesk_files_cancel", "crossdesk_system_command"].includes(d.name),
openWorldHint: true,
},
...((d.ui || d.app) && apps(ctx)
+44 -6
View File
@@ -66,7 +66,8 @@ interface UnlockRequest {
operation: string;
observation: string;
useSaved: boolean;
state: "preparing" | "awaiting_password" | "submitting" | "submitted" | "failed" | "cancelled";
state: "preparing" | "awaiting_password" | "submitting" | "verifying" | "verified" | "submitted" | "failed" | "cancelled";
verification?: "locked" | "unlocked" | "unknown";
expires: number;
generation?: number;
geometry?: number;
@@ -501,6 +502,13 @@ export class Sessions {
throw error;
}
}
async system(p: Data, signal?: AbortSignal): Promise<Data> {
const { session_id, ...args } = p;
const s = this.session(session_id);
if (args.command !== "status" && s.unlock && this.unlockPending(s.unlock))
throw new Failure("UNLOCK_PENDING");
return s.native.request("system", args, 7000, signal);
}
async control(id: string, enabled: boolean) {
if (!enabled) this.cancelUnlock(this.session(id));
return this.session(id).native.request("control", { enabled });
@@ -533,20 +541,23 @@ export class Sessions {
});
}
private unlockPending(request: UnlockRequest) {
return ["preparing", "awaiting_password", "submitting"].includes(request.state);
return ["preparing", "awaiting_password", "submitting", "verifying"].includes(request.state);
}
private unlockStatus(request: UnlockRequest): Data {
return {
request_id: request.id,
state: request.state,
...(request.verification ? { verification: request.verification } : {}),
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)
next_step: request.state === "verifying"
? "The remote system is verifying unlock completion. Wait with crossdesk_get_session and unlock_request_id."
: 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"
: ["submitted", "verified"].includes(request.state)
? "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.",
};
@@ -554,7 +565,7 @@ export class Sessions {
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.error = failure(new Failure(code, ["submitting", "verifying"].includes(request.state)));
request.state = "cancelled";
request.controller.abort();
}
@@ -621,6 +632,17 @@ export class Sessions {
request.state = "submitting";
const combined = signal ? AbortSignal.any([signal, request.controller.signal]) : request.controller.signal;
try {
const verify = s.status.capabilities?.system === true;
if (verify) {
const before = await this.system({ session_id: s.id, command: "status" }, combined);
if (combined.aborted) return;
if (before.lock_state === "unlocked") {
request.state = "verified";
request.verification = "unlocked";
request.result = { status: "already_unlocked" };
return;
}
}
// 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);
@@ -635,14 +657,30 @@ export class Sessions {
request.result = result;
if (request.controller.signal.aborted) return;
request.state = result.status === "submitted" ? "submitted" : "failed";
if (remember && request.state === "submitted") {
if (verify && request.state === "submitted") {
request.state = "verifying";
const deadline = Date.now() + 8000;
do {
const state = await this.system({ session_id: s.id, command: "status" }, combined);
request.verification = ["locked", "unlocked"].includes(state.lock_state) ? state.lock_state : "unknown";
if (request.verification !== "locked") break;
await delay(250, undefined, { signal: combined });
} while (Date.now() < deadline);
if (request.controller.signal.aborted) return;
request.state = request.verification === "unlocked" ? "verified" : "submitted";
if (request.verification === "locked") request.error = failure(new Failure("UNLOCK_NOT_CONFIRMED", true));
}
if (remember && (request.state === "verified" || (!verify && request.state === "submitted"))) {
try { await this.store.saveUnlock(s.device, password); request.saved = true; }
catch (error) { request.persistenceError = failure(error).code; }
} else if (remember && request.state === "submitted") {
request.persistenceError = "UNLOCK_NOT_CONFIRMED";
}
} catch (error) {
if (!request.controller.signal.aborted) {
request.state = "failed";
request.error = failure(error);
if (request.result?.status === "submitted") request.error.side_effect_possible = true;
}
} finally { password = ""; }
}
+7 -5
View File
@@ -142,6 +142,7 @@ const messages: Record<string, string> = {
UNLOCK_PENDING: "请先完成或取消当前解锁请求。",
UNLOCK_CREDENTIAL_REQUIRED: "请输入系统锁屏密码。",
UNLOCK_EXPIRED: "解锁请求已失效,请让 Agent 重新观察锁屏并发起请求。",
UNLOCK_NOT_CONFIRMED: "系统解锁状态尚待确认,请检查远端画面。",
NATIVE_UNLOCK_UNSUPPORTED: "请更新原生 CLI,以启用锁屏解锁。",
SAVE_DEVICE_REQUIRED: "请先保存设备,再保存锁屏密码。",
SAVE_UNLOCK_REQUIRED: "请勾选保存锁屏密码。",
@@ -285,7 +286,7 @@ function renderUnlock(s: Data) {
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 finished = ["submitted", "verified", "cancelled", "failed"].includes(request?.state);
const dialog = element<HTMLDialogElement>("unlock-dialog");
if (changed || !pending || !visible) {
input("unlock-password").value = "";
@@ -305,7 +306,7 @@ function renderUnlock(s: Data) {
element("session-unlock-row").hidden = !visible;
const states: Record<string, string> = {
preparing: "准备中", awaiting_password: "等待密码", submitting: "提交中",
submitted: "已提交", cancelled: "已取消", failed: "提交失败",
submitted: "已提交", verifying: "验证中", verified: "已解锁", cancelled: "已取消", failed: "提交失败",
};
text("session-unlock-state", states[request?.state] ?? "—");
element("session-unlock-state").dataset.state = request?.state ?? "";
@@ -320,12 +321,13 @@ function renderUnlock(s: Data) {
? "正在提交解锁,请稍候。" : "请确认远端系统密码框已聚焦,然后输入锁屏密码。"));
if (visible && pending && stateChanged) openUnlockDialog();
if (visible && finished) {
text("unlock-toast-title", request.state === "submitted" ? "解锁请求已提交"
text("unlock-toast-title", request.state === "verified" ? "系统已解锁" : request.state === "submitted" ? "解锁请求已提交"
: request.state === "cancelled" ? "解锁已取消" : "解锁操作未完成");
let message = request.state === "submitted" ? "Agent 将检查画面并继续任务。" : "";
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 = "密码保存失败,可通过本机 setup 重新配置。";
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;