diff --git a/apps/cli/docs/automation-protocol.md b/apps/cli/docs/automation-protocol.md index 5cd75d1e..07a0d32f 100644 --- a/apps/cli/docs/automation-protocol.md +++ b/apps/cli/docs/automation-protocol.md @@ -169,3 +169,7 @@ The desktop and CLI share `FileBrowser` and the existing file transfer engine. P `video_settings` accepts optional `fps`, even `width`/`height`, `quality`, and `max_bitrate_bps`. The controller checks negotiated preview capabilities and publishes the requested cadence and acceptance state. Supported MCP frame-rate tiers are 1, 8, 15, 30 and 60. The trusted adapter may use `display` with `preserve_input: true` during a cross-display drag. It captures a fresh observation after each switch, maps the requested point from its source display image, and releases input in a final cleanup step. Ordinary display selection releases input. Multi-display capture returns sequential observations and restores the previous display. + +## MCP connection recovery + +The MCP adapter optionally monitors saved, persistently authorized devices. Eligible failures receive bounded backoff retries within sixty seconds. Each replacement has a new session ID, device authorization is checked again, and disabled control remains disabled. User pause and explicit disconnect stop recovery. Queries against an old session can discover its replacement, while mutations require the replacement's ID and a fresh observation. MCP observation IDs are namespaced by session and translated back to native observation IDs at the adapter boundary. diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 3cb5dbcb..f07cffed 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -282,3 +282,9 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts `crossdesk_capture_displays` 按顺序截取最多八个显示器并恢复原显示器,通过一次工具结果展示多屏画面。`capture_id` 有效期三十秒,供 `crossdesk_drag_across` 使用各屏截图坐标完成跨屏拖拽。拖拽保持按下状态跨越显示器切换,在完成、取消或异常时释放按钮。普通输入操作使用重新选择显示器后取得的新截图。 `crossdesk_video_settings` 在运行时调整预览帧率、分辨率、质量和码率,返回远端接受状态。设置受对端能力限制。多屏采集及跨屏拖拽期间,同一会话的其他输入请求返回忙碌状态。 + +### 有界断线恢复 + +`crossdesk_set_recovery` 和面板的“断线自动恢复”开关用于启用恢复。默认关闭,启用要求设备已保存连接凭据及持久授权。后台监测覆盖临时信令中断、连接丢失和原生进程异常,使用退避策略,在六十秒预算内最多执行配置次数的重连。每次重连重新核对设备身份和授权,人工暂停、关闭恢复开关和主动断开会停止恢复。 + +恢复期间返回 `reconnecting`,可以用 `crossdesk_get_session` 携带 `session_id` 和 `wait_ms` 等待。恢复成功后返回新的 `session_id`,既有监看面板跟随新连接。旧会话标识可用于查询替代连接,后续操作使用新会话及新截图。截图标识包含所属会话,避免不同原生进程的观测编号相互混淆。先前的输入、解锁和文件任务保持原有结果边界,恢复流程只重建连接并恢复观看配置。 diff --git a/apps/mcp/src/browser.ts b/apps/mcp/src/browser.ts index d83bfd0d..5f69014e 100644 --- a/apps/mcp/src/browser.ts +++ b/apps/mcp/src/browser.ts @@ -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|unlock)$/, + /^\/api\/(ready|devices|status|connect|video_open|control|unlock|recovery)$/, )?.[1]; if (!name) throw new Failure("UNKNOWN_ACTION"); const { view_id, ...args } = body; diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index fc94a3d3..0cb9afee 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -39,6 +39,7 @@ const uiNames: Record = { video_open: "crossdesk_video_open", control: "crossdesk_viewer_control", unlock: "crossdesk_ui_unlock", + recovery: "crossdesk_ui_recovery", }; function output(value: Data, meta?: Data): CallToolResult { const data = { ...value }; @@ -74,7 +75,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. 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.", + "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. When state is reconnecting, continue bounded waits for the explicitly enabled recovery policy. Use the returned session_id after recovery and inspect a fresh screenshot. 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 = { @@ -245,14 +246,14 @@ export function createServer( 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 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.", + "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 with wait_ms waits for an active recovery. session_id without wait_ms 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"); + if (p.session_id && p.wait_ms) return output(await sessions.waitForSession(p.session_id, p.wait_ms, ctx.mcpReq.signal)); return output(p.view_id ? await sessions.waitForConnection(p.view_id, p.wait_ms ?? 25000, ctx.mcpReq.signal) : await sessions.status(p.session_id, undefined, ctx.mcpReq.signal)); @@ -262,6 +263,10 @@ export function createServer( add("crossdesk_list_sessions", z.strictObject({}), "List the active remote sessions with their device aliases, displays and control state. Each session_id addresses one connection. Up to eight connections can be active concurrently.", async () => output(await sessions.listSessions()), { readOnly: true }); + add("crossdesk_set_recovery", z.strictObject({ session_id: id, enabled: z.boolean(), + max_attempts: z.int().min(1).max(5).default(3) }), + "Enable or disable bounded background connection recovery for a saved device with persistent authorization and credentials. Recovery uses a new session_id and never resubmits previous input, unlock or file operations. Wait with crossdesk_get_session and use its returned session_id plus a fresh screenshot before continuing. Human pause and explicit disconnect stop recovery.", + async (p) => output(await sessions.setRecovery(p.session_id, p.enabled, p.max_attempts))); add("crossdesk_capture_displays", z.strictObject({ session_id: id, display_indices: z.array(z.int().min(0).max(63)).min(1).max(8) .refine((values) => new Set(values).size === values.length).optional(), diff --git a/apps/mcp/src/sessions.ts b/apps/mcp/src/sessions.ts index 51ebed0d..4d9a3b90 100644 --- a/apps/mcp/src/sessions.ts +++ b/apps/mcp/src/sessions.ts @@ -59,6 +59,15 @@ interface Session { connecting: boolean; connection: AbortController; displayBatch?: boolean; + mode: string; + desiredControl: boolean; + recoveryEnabled: boolean; + recoveryAttempts: number; + recoveryAttempt: number; + recovering?: Promise; + recoveryAbort?: AbortController; + monitor?: ReturnType; + recoveredFrom?: string; statusRequest?: Promise; closing?: Promise; unlock?: UnlockRequest; @@ -84,6 +93,7 @@ export class Sessions { readonly views = new Map(); private current?: Session; private active = new Map(); + private replacements = new Map(); private captures = new Map(); private drags = new Map(); private connecting = new Set(); @@ -160,6 +170,7 @@ export class Sessions { }; } private session(id: string): Session { + if (this.replacements.has(id)) throw new Failure("SESSION_REPLACED"); const session = this.active.get(id); if (!session) throw new Failure("SESSION_NOT_FOUND"); return session; @@ -168,7 +179,16 @@ export class Sessions { const view = viewId ? this.view(viewId) : undefined; const id = sessionId ?? view?.session; if (!id && view) return this.describe(view); + const replacement = this.replacements.get(id!); + if (replacement) return { ...await this.status(replacement, undefined, signal), previous_session_id: id }; const s = this.session(id!); + if (s.recovering) return { + ...s.status, error: "", frame_age_ms: null, + ...(view ? this.describe(view) : {}), session_id: s.id, device_alias: s.device.alias, + state: "reconnecting", control: false, reconnect_enabled: s.recoveryEnabled, + recovery_attempt: s.recoveryAttempt, + next_step: "Authorized connection recovery is running. Wait with crossdesk_get_session. Use the returned session_id and a fresh screenshot after recovery. Query uncertain task outcomes without replaying input.", + }; // A panel connection may still be performing the native hello handshake. // Reading native status here would race initialization and could close it. if (s.connecting) return { @@ -186,15 +206,17 @@ export class Sessions { .then((status) => (s.status = status)) .catch((error) => { if (!(error instanceof Failure) || error.code !== "SESSION_CLOSED") throw error; - return (s.status = { state: "closed", control: false }); + return (s.status = { ...s.status, state: "closed", control: false, error: "native_process_closed" }); }) .finally(() => { s.statusRequest = undefined; }); await this.waitStatus(s.statusRequest, signal); } + if (s.recovering) return this.status(sessionId, viewId, signal); // The user may have disconnected or replaced the session during the read. if (this.active.get(s.id) !== s || (view && view.session !== id)) return view ? this.describe(view) : { session_id: id, state: "closed", control: false }; if (["closed", "failed"].includes(s.status.state)) { + if (this.startRecovery(s)) return this.status(sessionId, viewId, signal); await this.dispose(s); } this.checkUnlock(s); @@ -202,6 +224,9 @@ export class Sessions { ...s.status, session_id: s.id, device_alias: s.device.alias, + reconnect_enabled: s.recoveryEnabled, + recovery_attempt: s.recoveryAttempt, + ...(s.recoveredFrom ? { recovered_from: s.recoveredFrom } : {}), ...(s.unlock ? { unlock: this.unlockStatus(s.unlock) } : {}), ...(view ? this.describe(view) : {}), state: s.status.state, @@ -213,7 +238,7 @@ export class Sessions { ...(s.unlock && this.unlockPending(s.unlock) ? { next_step: this.unlockStatus(s.unlock).next_step } : {}), }; } - private async waitStatus(request: Promise, signal?: AbortSignal) { + private async waitStatus(request: Promise, signal?: AbortSignal) { if (!signal) return request; let abort: () => void = () => {}; try { @@ -228,8 +253,122 @@ export class Sessions { } private dispose(s: Session): Promise { this.cancelUnlock(s); + clearInterval(s.monitor); + s.monitor = undefined; return s.closing ??= s.native.close().finally(() => s.release()); } + private monitor(s: Session) { + clearInterval(s.monitor); + if (!s.recoveryEnabled || s.closing) return; + s.monitor = setInterval(() => { + if (s.recovering || s.connecting || this.closing) return; + void this.status(s.id).catch((error) => { + if (this.active.get(s.id) !== s || s.closing || s.recovering) return; + if (failure(error).code === "NATIVE_TIMEOUT") { + s.status = { ...s.status, state: "failed", error: "native_process_unresponsive" }; + this.startRecovery(s); + } + }); + }, 1000); + s.monitor.unref(); + } + private startRecovery(s: Session): boolean { + if (!s.recoveryEnabled || s.closing || this.closing || s.status.human_paused || + !["connection_lost", "signaling_disconnected", "native_process_closed", "native_process_unresponsive"].includes(s.status.error)) + return false; + if (s.recovering) return true; + clearInterval(s.monitor); + const controller = s.recoveryAbort = new AbortController(); + s.recovering = this.recover(s, controller.signal).finally(() => { s.recovering = undefined; }); + return true; + } + private async recover(s: Session, signal: AbortSignal) { + const views = [...this.views.values()].filter((view) => view.session === s.id); + const display = s.status.display ?? 0; + try { await this.dispose(s); } + catch (error) { + s.recoveryEnabled = false; + s.status = { ...s.status, state: "failed", recovery_error: failure(error) }; + return; + } + const deadline = Date.now() + 60000; + for (let attempt = 1; attempt <= s.recoveryAttempts && Date.now() < deadline; attempt++) { + s.recoveryAttempt = attempt; + try { + await delay(Math.min(500 * 3 ** (attempt - 1), 5000), undefined, { signal }); + if (!s.recoveryEnabled || this.closing || signal.aborted) throw new Failure("CANCELLED"); + const device = await this.store.device(s.device.alias); + if (device.device_id !== s.device.device_id || device.server !== s.device.server || device.port !== s.device.port) + throw new Failure("DEVICE_ALIAS_CONFLICT"); + const timeout = AbortSignal.timeout(Math.max(1, deadline - Date.now())); + const result = await this.connectSaved(s.device.alias, s.mode, display, + AbortSignal.any([signal, timeout]), s.desiredControl); + const next = this.session(result.session_id); + if (signal.aborted || this.closing || !s.recoveryEnabled) { + await this.disconnect(next.id); + throw new Failure("CANCELLED"); + } + if (!s.desiredControl) await next.native.request("control", { enabled: false }); + next.desiredControl = s.desiredControl; + if (next.status.preview_supported && s.status.preview_supported) { + const settings: Data = { fps: s.status.preview_fps, quality: s.status.preview_quality }; + if (next.status.preview_resolution_supported) Object.assign(settings, { + width: s.status.preview_width, height: s.status.preview_height }); + if (next.status.preview_bitrate_supported) settings.max_bitrate_bps = s.status.preview_bitrate_bps; + await next.native.request("video_settings", settings).catch(() => {}); + } + next.recoveryEnabled = true; + next.recoveryAttempts = s.recoveryAttempts; + next.recoveryAttempt = attempt; + next.recoveredFrom = s.id; + for (const view of views) if (this.views.get(view.id) === view && view.session === s.id) { + view.session = next.id; + view.disconnected = false; + view.connectionError = undefined; + } + for (const [old, replacement] of this.replacements) if (replacement === s.id) this.replacements.set(old, next.id); + if (this.replacements.size >= 128) this.replacements.delete(this.replacements.keys().next().value!); + this.replacements.set(s.id, next.id); + this.active.delete(s.id); + this.monitor(next); + return; + } catch (error) { + s.status = { ...s.status, state: "failed", control: false, recovery_error: failure(error) }; + if (signal.aborted || this.closing || !s.recoveryEnabled || + ["AUTH_REQUIRED", "AUTH_FAILED", "DEVICE_ALIAS_CONFLICT", "CANCELLED"].includes(failure(error).code) || + failure(error).reason === "tls_certificate_error") break; + } + } + s.recoveryEnabled = false; + s.status = { ...s.status, state: signal.aborted ? "closed" : "failed", control: false, recovery_exhausted: !signal.aborted }; + } + async setRecovery(sessionId: string, enabled: boolean, attempts = 3): Promise { + const s = this.session(sessionId); + if (enabled) { + if (s.closing) throw new Failure("SESSION_CLOSED"); + const device = await this.store.device(s.device.alias); + if (device.device_id !== s.device.device_id || device.server !== s.device.server || device.port !== s.device.port) + throw new Failure("DEVICE_ALIAS_CONFLICT"); + if (!device.authorization || (s.mode === "control" && device.authorization !== "control")) + throw new Failure("AUTH_REQUIRED"); + await this.store.password(device); + if (s.closing || this.active.get(s.id) !== s) throw new Failure("SESSION_CLOSED"); + } + s.recoveryEnabled = enabled; + s.recoveryAttempts = attempts; + if (!enabled) s.recoveryAbort?.abort(); + this.monitor(s); + return { session_id: s.id, reconnect_enabled: enabled, max_attempts: attempts }; + } + async waitForSession(sessionId: string, waitMs: number, signal?: AbortSignal): Promise { + const deadline = Date.now() + waitMs; + while (true) { + const result = await this.status(sessionId, undefined, signal); + if (!["connecting", "reconnecting"].includes(result.state)) return result; + if (Date.now() >= deadline) return { ...result, wait_timed_out: true }; + await delay(Math.min(200, deadline - Date.now()), undefined, { signal }); + } + } async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise { const deadline = Date.now() + waitMs; let result = this.describe(this.view(viewId)); @@ -267,6 +406,7 @@ export class Sessions { mode: string, display: number, signal?: AbortSignal, + initialControl = mode === "control", ): Promise { const d = await this.store.device(name); if ( @@ -276,11 +416,12 @@ export class Sessions { throw new Failure("AUTH_REQUIRED"); return this.connect( d, - await this.store.password(d), + await this.waitStatus(this.store.password(d), signal), mode, display, undefined, signal, + initialControl, ); } async connectUI(v: View, args: unknown, signal?: AbortSignal): Promise { @@ -337,6 +478,7 @@ export class Sessions { display: number, view?: View, signal?: AbortSignal, + initialControl = mode === "control", ): Promise { if (this.closing) throw new Failure("SESSION_CLOSED"); const established = [...this.active.values()].filter((s) => !s.connecting && !s.closing).length; @@ -352,7 +494,7 @@ export class Sessions { let session: Session | undefined; try { for (const s of this.active.values()) { - if (s.closing) { + if (s.closing && !s.recovering) { this.active.delete(s.id); for (const v of this.views.values()) if (v.session === s.id) { v.session = undefined; @@ -392,6 +534,11 @@ export class Sessions { release, connecting: true, connection: controller, + mode, + desiredControl: initialControl, + recoveryEnabled: false, + recoveryAttempts: 3, + recoveryAttempt: 0, unlocks: new Map(), }; this.current = s; @@ -409,6 +556,8 @@ export class Sessions { signal, ); password = ""; + if (mode === "control" && !initialControl) + await native.request("control", { enabled: false }, 5000, signal); const deadline = Date.now() + 35000; while (Date.now() < deadline) { if (this.closing || signal?.aborted) throw new Failure("CANCELLED"); @@ -448,15 +597,26 @@ export class Sessions { } async screenshot(p: Data, signal?: AbortSignal): Promise { const { session_id, ...args } = p; - if (this.session(session_id).displayBatch) throw new Failure("BUSY"); + const s = this.session(session_id); + if (s.displayBatch) throw new Failure("BUSY"); if (args.after_frame_id !== undefined && args.generation === undefined) throw new Failure("GENERATION_REQUIRED"); - return this.session(session_id).native.request( + const result = await s.native.request( "screenshot", args, (args.timeout_ms ?? 2000) + 3000, signal, ); + return this.publicObservation(s, result); + } + private publicObservation(s: Session, observation: Data): Data { + return { ...observation, metadata: { ...observation.metadata, + observation_id: `${s.id}_${observation.metadata.observation_id}` } }; + } + private nativeObservation(s: Session, observation: string): string { + const prefix = `${s.id}_`; + if (!observation.startsWith(prefix)) throw new Failure("STALE_OBSERVATION"); + return observation.slice(prefix.length); } async action(p: Data, signal?: AbortSignal): Promise { const { session_id, observe_after = true, ...args } = p; @@ -464,6 +624,7 @@ export class Sessions { if (s.displayBatch) throw new Failure("BUSY"); this.checkUnlock(s); if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING"); + args.observation_id = this.nativeObservation(s, args.observation_id); const result = await s.native.request("action", args, (args.action.duration_ms ?? 0) + 5000, signal); if (observe_after && result.status === "submitted") { try { @@ -485,7 +646,9 @@ export class Sessions { } async waitFor(p: Data, signal?: AbortSignal): Promise { const { session_id, ...args } = p; - const result = await this.session(session_id).native.request( + const s = this.session(session_id); + args.observation_id = this.nativeObservation(s, args.observation_id); + const result = await s.native.request( "wait_frame", args, (args.timeout_ms ?? 5000) + 3000, signal); if (result.matched) result.observation = await this.screenshot({ session_id }, signal); return result; @@ -511,7 +674,7 @@ export class Sessions { for (const index of indices) { await native.request("display", { index }, 5000, signal); const observation = await native.request("screenshot", { max_edge: p.max_edge ?? 1024 }, 5000, signal); - observations.push({ ...observation, actionable: false }); + observations.push({ ...this.publicObservation(s, observation), actionable: false }); } } catch (failure) { error = failure; } finally { @@ -529,14 +692,14 @@ export class Sessions { return { session_id: s.id, capture_id, observations, restored_display: status.display }; } async dragAcross(p: Data, signal?: AbortSignal): Promise { - const s = this.session(p.session_id); - const key = `${s.id}:${p.operation_id}`; + const key = `${p.session_id}:${p.operation_id}`; const args = JSON.stringify({ capture_id: p.capture_id, path: p.path, button: p.button ?? "left", duration_ms: p.duration_ms ?? 500 }); const previous = this.drags.get(key); if (previous) { if (previous.arguments !== args) throw new Failure("OPERATION_ID_CONFLICT"); return previous.result; } + const s = this.session(p.session_id); const capture = this.captures.get(p.capture_id); if (!capture || capture.session !== s.id || capture.native !== s.native || Date.now() - capture.created > 30000) throw new Failure("STALE_OBSERVATION"); @@ -583,7 +746,7 @@ export class Sessions { } await submit("mouse_up", p.path.at(-1)); record.result = { operation_id: p.operation_id, status: "submitted", side_effect_possible: true, - observation: await native.request("screenshot", {}, 5000, signal) }; + observation: this.publicObservation(s, await native.request("screenshot", {}, 5000, signal)) }; } catch (error) { record.result = { operation_id: p.operation_id, status: submitted ? "outcome_unknown" : "failed_before_submit", error: failure(error), side_effect_possible: submitted }; @@ -655,8 +818,11 @@ export class Sessions { return s.native.request("release_inputs"); } async control(id: string, enabled: boolean) { - if (!enabled) this.cancelUnlock(this.session(id)); - return this.session(id).native.request("control", { enabled }); + const s = this.session(id); + if (!enabled) { this.cancelUnlock(s); s.desiredControl = false; } + const result = await s.native.request("control", { enabled }); + s.desiredControl = enabled; + return result; } async display(id: string, index: number) { const s = this.session(id); @@ -666,7 +832,11 @@ export class Sessions { return s.native.request("display", { index }); } async disconnect(id: string) { + id = this.replacements.get(id) ?? id; const s = this.session(id); + s.recoveryEnabled = false; + s.recoveryAbort?.abort(); + clearInterval(s.monitor); if (s.connecting) s.connection.abort(); for (const v of this.views.values()) if (v.session === id) { @@ -675,6 +845,7 @@ export class Sessions { v.connectionError = undefined; } await this.dispose(s); + await s.recovering?.catch(() => {}); this.active.delete(s.id); if (this.current === s) this.current = [...this.active.values()].find((other) => !other.closing); return { session_id: id, state: "closed" }; @@ -682,10 +853,20 @@ 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", { + if (operation === "pause") { + const s = this.session(v.session); + this.cancelUnlock(s); + s.status.human_paused = true; + s.desiredControl = false; + s.recoveryAbort?.abort(); + } + const s = this.session(v.session); + const result = await s.native.request("viewer_control", { operation, }); + if (operation === "resume") s.desiredControl = true; + s.status = result; + return result; } private unlockPending(request: UnlockRequest) { return ["preparing", "awaiting_password", "submitting", "verifying"].includes(request.state); @@ -745,7 +926,7 @@ export class Sessions { 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); + const prepared = await s.native.request("unlock_prepare", { observation_id: this.nativeObservation(s, p.observation_id) }, 5000, signal); request.generation = prepared.generation; request.geometry = prepared.geometry_epoch; let password: string | undefined; @@ -799,7 +980,7 @@ export class Sessions { throw new Failure("STALE_OBSERVATION"); const result = await s.native.request("unlock", { operation_id: request.operation, - observation_id: metadata.observation_id, + observation_id: this.nativeObservation(s, metadata.observation_id), password, }, 5000, combined); request.result = result; diff --git a/apps/mcp/src/ui-actions.ts b/apps/mcp/src/ui-actions.ts index b56b503c..039364d3 100644 --- a/apps/mcp/src/ui-actions.ts +++ b/apps/mcp/src/ui-actions.ts @@ -8,6 +8,7 @@ export const uiSchemas = { status: z.strictObject({}), connect: uiConnect, video_open: z.strictObject({}), + recovery: z.strictObject({ enabled: z.boolean(), max_attempts: z.int().min(1).max(5).default(3) }), unlock: z.strictObject({ request_id: id, password: unlockPassword.optional(), @@ -49,6 +50,9 @@ export async function uiAction( case "unlock": if (!p.cancel && !p.password) throw new Failure("UNLOCK_CREDENTIAL_REQUIRED"); return sessions.unlockUI(view, p, signal); + case "recovery": + if (!view.session) throw new Failure("NOT_READY"); + return sessions.setRecovery(view.session, p.enabled, p.max_attempts); default: throw new Failure("UNKNOWN_ACTION"); } diff --git a/apps/mcp/ui/viewer.ts b/apps/mcp/ui/viewer.ts index dd728ec5..614d6a13 100644 --- a/apps/mcp/ui/viewer.ts +++ b/apps/mcp/ui/viewer.ts @@ -46,6 +46,7 @@ root.innerHTML = ` @@ -130,6 +131,7 @@ const messages: Record = { display_not_available: "所选显示器当前不可用,请检查显示器索引。", media_initialization_failed: "本机媒体组件初始化失败,请检查原生 CLI 运行环境。", control_event_overflow: "连接事件队列已满,请稍后重新连接。", + SESSION_REPLACED: "连接已恢复,请使用更新后的会话。", SESSION_LIMIT: "活动连接已达到八个,请先断开一个连接。", DEVICE_BUSY: "此设备已有活动连接。", CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。", @@ -380,8 +382,8 @@ function renderStatus(s: Data) { root.dataset.controlling = String(connected && s.control === true && s.control_allowed === true && !s.human_paused); element("connection").dataset.state = connected ? "online" : connecting ? "connecting" : lost && !disconnectedByUser ? "offline" : "idle"; text("connection-label", connected ? "已连接" : connecting ? "正在连接" : lost ? "未连接" : "等待连接"); - element("connect-form").hidden = connected; - element("session-card").hidden = !connected; + element("connect-form").hidden = connected || s.state === "reconnecting"; + element("session-card").hidden = !connected && s.state !== "reconnecting"; text("sidebar-title", connected ? "连接信息" : "连接设备"); text("device-name", connected ? s.device_alias || "远程电脑" : "远程画面"); if (!connected || changedSession) setDetails(false); @@ -396,7 +398,9 @@ function renderStatus(s: Data) { text("session-device-id", device ? String(device.device_id).replace(/(\d{3})(?=\d)/g, "$1 ") : ""); text("session-access", s.control_allowed ? "查看与操作" : "仅查看"); text("session-display", `显示器 ${(s.display ?? 0) + 1}`); - text("session-agent", s.human_paused ? "已暂停" : s.control_allowed ? "允许操作" : "仅可观察"); + text("session-agent", s.state === "reconnecting" ? "正在恢复连接" : s.human_paused ? "已暂停" : s.control_allowed ? "允许操作" : "仅可观察"); + input("recovery").checked = s.reconnect_enabled === true; + input("recovery").disabled = !s.session_id; element("session-agent").dataset.paused = String(!!s.human_paused); element("pause").disabled = !connected || !s.control_allowed || controlBusy; const pauseLabel = s.human_paused ? "恢复控制" : "暂停控制"; @@ -477,6 +481,13 @@ element("connect-form").addEventListener("keydown", (event) => { void connect(); } }); +input("recovery").onchange = async () => { + input("recovery").disabled = true; + try { + await invoke("recovery", { enabled: input("recovery").checked }); + renderStatus(await invoke("status")); + } catch (error) { showError(error); renderStatus(status); } +}; element("pause").onclick = async () => { if (controlBusy) return; controlBusy = true; @@ -653,6 +664,7 @@ async function start() { video_open: "crossdesk_video_open", control: "crossdesk_viewer_control", unlock: "crossdesk_ui_unlock", + recovery: "crossdesk_ui_recovery", }; let connected = false, initial: Data | undefined;