diff --git a/apps/cli/agent.cpp b/apps/cli/agent.cpp index 9283661a..5f968853 100644 --- a/apps/cli/agent.cpp +++ b/apps/cli/agent.cpp @@ -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"}); diff --git a/apps/cli/docs/automation-protocol.md b/apps/cli/docs/automation-protocol.md index 10da036c..05ea794e 100644 --- a/apps/cli/docs/automation-protocol.md +++ b/apps/cli/docs/automation-protocol.md @@ -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. diff --git a/apps/desktop/src/common/screen_lock.h b/apps/desktop/src/common/screen_lock.h index 08ba549c..418cc124 100644 --- a/apps/desktop/src/common/screen_lock.h +++ b/apps/desktop/src/common/screen_lock.h @@ -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 diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index cd74be2f..9056b5e0 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -2609,6 +2609,7 @@ void GuiApplication::Tick() { } } clipboard_.ApplyPendingRemoteText(); + ProcessAutomationRequests(); #ifndef __OHOS__ std::string activation_token; bool activation_requested = diff --git a/apps/desktop/src/gui/runtime/automation_runtime.cpp b/apps/desktop/src/gui/runtime/automation_runtime.cpp index 6c82a833..c512cb67 100644 --- a/apps/desktop/src/gui/runtime/automation_runtime.cpp +++ b/apps/desktop/src/gui/runtime/automation_runtime.cpp @@ -1,5 +1,6 @@ #include "runtime/gui_runtime.h" #include +#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(); + 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 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 diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index 2c5cf75b..e08514e7 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -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); diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index d4913dcb..c02c9c69 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -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> automation_tasks_; + bool automation_closing_ = false; InputOutcome InjectRemoteMouse(const RemoteAction& action, int display); std::string gallery_group_control_id_; diff --git a/apps/desktop/src/platform/linux/screen_lock.cpp b/apps/desktop/src/platform/linux/screen_lock.cpp index 92eeafa2..700d0047 100644 --- a/apps/desktop/src/platform/linux/screen_lock.cpp +++ b/apps/desktop/src/platform/linux/screen_lock.cpp @@ -7,11 +7,54 @@ #include "screen_lock.h" #include +#include #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; diff --git a/apps/desktop/src/platform/macos/screen_lock.cpp b/apps/desktop/src/platform/macos/screen_lock.cpp index 861dcf41..c843de3f 100644 --- a/apps/desktop/src/platform/macos/screen_lock.cpp +++ b/apps/desktop/src/platform/macos/screen_lock.cpp @@ -7,11 +7,23 @@ #include "screen_lock.h" #include +#include #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. diff --git a/apps/desktop/src/platform/windows/screen_lock.cpp b/apps/desktop/src/platform/windows/screen_lock.cpp index 6baf2630..8eca0daf 100644 --- a/apps/desktop/src/platform/windows/screen_lock.cpp +++ b/apps/desktop/src/platform/windows/screen_lock.cpp @@ -5,6 +5,7 @@ */ #include "screen_lock.h" +#include "service/interactive_desktop.h" #include @@ -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()); diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index e288be2a..4e8f1f87 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -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") diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 7c37b19a..58f388ed 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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`。旧被控端沿用截图确认流程。 diff --git a/apps/mcp/src/native.ts b/apps/mcp/src/native.ts index c01b7a7c..c47e20f8 100644 --- a/apps/mcp/src/native.ts +++ b/apps/mcp/src/native.ts @@ -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) diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 31f20003..ed3d0411 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -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) diff --git a/apps/mcp/src/sessions.ts b/apps/mcp/src/sessions.ts index 0e308bf7..5d9324bb 100644 --- a/apps/mcp/src/sessions.ts +++ b/apps/mcp/src/sessions.ts @@ -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 { + 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 = ""; } } diff --git a/apps/mcp/ui/viewer.ts b/apps/mcp/ui/viewer.ts index f318f32f..8c0dc384 100644 --- a/apps/mcp/ui/viewer.ts +++ b/apps/mcp/ui/viewer.ts @@ -142,6 +142,7 @@ const messages: Record = { 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("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 = { 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;