//! Unity 插件与 Agent Runtime 共用的执行服务。helper 生命周期与执行不确定门闩分离。 mod transport; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock, TryLockError}; use std::time::{Duration, Instant}; use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; use serde::Deserialize; use serde_json::{json, Value}; pub const UNITY_EDITOR_ADAPTER_ID: &str = "unity-editor"; pub const PROTOCOL_VERSION: &str = "agc.unity.attach.v1"; pub const MAX_EXECUTE_CODE_BYTES: usize = 128 * 1024; pub const MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024; pub const DEFAULT_COMMAND_TIMEOUT_MS: u32 = 60_000; const HELPER_SUFFIX: &[&str] = &["dotnet", "publish", "win-x64", "Agc.Unity.Attach.exe"]; pub fn is_supported_platform() -> bool { cfg!(all(target_os = "windows", target_arch = "x86_64")) } fn service() -> &'static UnityEditorService { static SERVICE: OnceLock = OnceLock::new(); SERVICE.get_or_init(UnityEditorService::default) } /// 候选路径由宿主内置插件注册流程提供,模型和插件 RPC 不能改写。 pub fn configure_helper_candidates(candidates: Vec) -> Result<(), String> { service().configure(candidates) } /// 项目切换只使连接失效;不等待正在执行的 C#,也不清除执行结果不确定状态。 pub fn disconnect_unity_editor() { service().disconnect(); } pub fn execute_unity_editor_code_for_project( project_path: &str, code: &str, timeout_ms: u32, ) -> Result { execution_result(service().call( "execute", RpcParams { project_path: Some(project_path.into()), code: Some(code.into()), timeout_ms: Some(timeout_ms), ..Default::default() }, )) } #[derive(Default)] struct UnityEditorService { candidates: Mutex>, state: Mutex, epoch: AtomicU64, uncertain: AtomicBool, } #[derive(Default)] struct ServiceState { helper: Option, epoch: u64, next_id: u64, } impl UnityEditorService { fn configure(&self, candidates: Vec) -> Result<(), String> { for candidate in &candidates { validate_candidate(candidate)?; } let mut current = self .candidates .lock() .map_err(|_| "Unity helper 配置锁损坏")?; if *current != candidates { *current = candidates; self.disconnect(); } Ok(()) } fn disconnect(&self) { self.epoch.fetch_add(1, Ordering::SeqCst); if let Ok(mut state) = self.state.try_lock() { state.helper.take(); } } fn helper_path(&self) -> Result { let candidates = self .candidates .lock() .map_err(|_| "Unity helper 配置锁损坏")?; for candidate in candidates.iter() { if !candidate.is_file() { continue; } validate_candidate(candidate)?; let metadata = fs::symlink_metadata(candidate).map_err(|_| "Unity helper 不可读")?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("Unity helper 必须是插件包中的普通文件".into()); } let root = candidate .ancestors() .nth(4) .ok_or("Unity helper 包目录无效")?; let root = fs::canonicalize(root).map_err(|_| "Unity helper 包目录不可读")?; let path = fs::canonicalize(candidate).map_err(|_| "Unity helper 不可读")?; if !path.starts_with(&root) { return Err("Unity helper 路径越出插件包目录".into()); } return Ok(path); } Err("Unity 插件未提供当前平台的 Attach helper,请先构建或安装完整插件".into()) } fn call(&self, method: &str, params: RpcParams) -> Result { if method == "connect" { // 即使新目标参数错误,也不能继续发布上一条连接。 self.disconnect(); } let request_epoch = self.epoch.load(Ordering::SeqCst); let project = normalize_project(params.project_path.as_deref().ok_or("缺少 projectPath")?)?; let timeout = params.timeout_ms.unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS); if timeout == 0 || timeout > 60_000 { return Err("timeoutMs 必须在 1..=60000".into()); } if params.process_id == Some(0) { return Err("processId 必须大于 0".into()); } if method == "execute" { validate_code(params.code.as_deref().ok_or("缺少 code")?)?; if self.uncertain.load(Ordering::SeqCst) { return Ok(reconciliation( "先前 Unity 执行结果待核对,当前宿主不再发送执行命令", )); } } else if params.code.is_some() { return Err("只有 execute 可以提供 code".into()); } if method == "disconnect" { self.disconnect(); return Ok( json!({"adapter": UNITY_EDITOR_ADAPTER_ID, "connected": false, "pid": null, "projectPath": project, "version": null, "startedUtc": null, "generation": null}), ); } if !is_supported_platform() { return Err("Unity Attach 仅支持 Windows x64 Mono Editor".into()); } // 外层期限包含 helper 启动、stdin 写入和 stdout 读取;不在互斥锁后排队。 let deadline = Instant::now() + Duration::from_millis(u64::from(timeout) + 10_000); let mut state = match self.state.try_lock() { Ok(state) => state, Err(TryLockError::WouldBlock) => { return Err("Unity 编辑器已有请求正在执行,请等待回执".into()) } Err(TryLockError::Poisoned(_)) => { self.uncertain.store(true, Ordering::SeqCst); return Ok(reconciliation("Unity 执行服务状态异常,需要核对")); } }; if method == "execute" && self.uncertain.load(Ordering::SeqCst) { return Ok(reconciliation("先前 Unity 执行结果待核对")); } let epoch = self.epoch.load(Ordering::SeqCst); if epoch != request_epoch { return Err("Unity 项目连接在派发前已切换,未发送请求".into()); } if state.epoch != epoch { state.helper.take(); state.epoch = epoch; } if state.helper.is_none() { state.helper = Some(transport::HelperProcess::spawn(&self.helper_path()?)?); } state.next_id = state.next_id.checked_add(1).ok_or("Unity 请求 id 已耗尽")?; let id = state.next_id; let mut input = json!({"projectPath": project, "timeoutMs": timeout}); if let Some(pid) = params.process_id { input["processId"] = json!(pid); } if let Some(code) = params.code { input["code"] = json!(code); } let request = json!({"jsonrpc":"2.0", "protocol": PROTOCOL_VERSION, "id": id, "method": method, "params": input}); let response = state .helper .as_mut() .expect("helper initialized") .exchange(&request, deadline); let result = match response { Ok(bytes) => parse_response(&bytes, id, method, &project).and_then(|result| { if method != "execute" && params.process_id.is_some_and(|pid| { result.get("pid").and_then(Value::as_u64) != Some(u64::from(pid)) }) { return Err("Unity helper 回执 PID 与指定目标不一致".into()); } Ok(result) }), Err(error) => Err(error), }; let result = self.finish_response(method, result); // 旧请求的回执仍返回原调用方,但不能留下一条可供新项目复用的连接。 if self.epoch.load(Ordering::SeqCst) != epoch || result.is_err() || result .as_ref() .ok() .and_then(|v| v.get("status")) .and_then(Value::as_str) == Some("needs-reconciliation") { state.helper.take(); } result } fn finish_response( &self, method: &str, response: Result, ) -> Result { if method != "execute" { return response; } match response { Ok(value) if value.get("status").and_then(Value::as_str) != Some("needs-reconciliation") => { Ok(value) } Ok(value) => { self.uncertain.store(true, Ordering::SeqCst); Ok(value) } Err(error) => { self.uncertain.store(true, Ordering::SeqCst); Ok(reconciliation(&error)) } } } } fn validate_candidate(candidate: &Path) -> Result<(), String> { if !candidate.is_absolute() || candidate .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { return Err("Unity helper 候选必须是固定插件包内绝对路径".into()); } let suffix: PathBuf = HELPER_SUFFIX.iter().collect(); if !candidate.ends_with(suffix) { return Err("Unity helper 只允许固定 dotnet/publish/win-x64/Agc.Unity.Attach.exe".into()); } Ok(()) } fn normalize_project(project: &str) -> Result { let path = Path::new(project); if !path.is_absolute() || !path.is_dir() { return Err("Unity projectPath 必须是现存绝对目录".into()); } let path = fs::canonicalize(path).map_err(|_| "Unity 项目目录不可读")?; for (part, directory) in [ ("Assets", true), ("Packages", true), ("ProjectSettings", true), ("ProjectSettings/ProjectVersion.txt", false), ] { let metadata = fs::symlink_metadata(path.join(part)).map_err(|_| format!("Unity 项目缺少 {part}"))?; if metadata.file_type().is_symlink() || (directory && !metadata.is_dir()) || (!directory && !metadata.is_file()) { return Err(format!( "Unity 项目标记不是普通{}:{part}", if directory { "目录" } else { "文件" } )); } } Ok(path.to_string_lossy().into_owned()) } pub fn validate_code(code: &str) -> Result<(), String> { if code.trim().is_empty() { return Err("execute.code 不能为空".into()); } if code.contains('\0') { return Err("execute.code 不能包含 NUL".into()); } if code.len() > MAX_EXECUTE_CODE_BYTES { return Err("execute.code 超过 128 KiB 上限".into()); } Ok(()) } fn reconciliation(message: &str) -> Value { json!({"ok": false, "status": "needs-reconciliation", "retryAllowed": false, "dispatched": true, "error": {"code":"execution-uncertain", "message": message}}) } /// call 在可能写入后只返回已核验终态或 needs-reconciliation;剩余 Err 都确认未派发。 fn execution_result(result: Result) -> Result { Ok(result.unwrap_or_else(|message| { json!({"ok":false,"status":"failed","retryAllowed":false,"dispatched":false, "error":{"code":"not-dispatched","message":message}}) })) } fn parse_response(bytes: &[u8], id: u64, method: &str, project: &str) -> Result { if bytes.len() > MAX_MESSAGE_BYTES { return Err("Unity helper 响应超过 2 MiB".into()); } let envelope: Value = serde_json::from_slice(bytes).map_err(|_| "Unity helper 返回无效 JSON")?; if envelope.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || envelope.get("protocol").and_then(Value::as_str) != Some(PROTOCOL_VERSION) || envelope.get("id").and_then(Value::as_u64) != Some(id) { return Err("Unity helper 响应协议或请求 id 不匹配".into()); } if envelope.get("error").is_some() { return Err("Unity helper 返回协议错误,缺少可信操作回执".into()); } let result = envelope .get("result") .filter(|r| r.is_object()) .ok_or("Unity helper 缺少结果对象")? .clone(); if method == "execute" { let status = result .get("status") .and_then(Value::as_str) .ok_or("Unity helper 缺少执行状态")?; let ok = result .get("ok") .and_then(Value::as_bool) .ok_or("Unity helper 缺少执行回执")?; let dispatched = result .get("dispatched") .and_then(Value::as_bool) .ok_or("Unity helper 缺少发送状态")?; if result.get("retryAllowed").and_then(Value::as_bool) != Some(false) { return Err("Unity helper 缺少禁止自动重放的回执".into()); } match status { "completed" if ok && dispatched && result.get("result").is_some() => {} "failed" if !ok && valid_error(result.get("error")) => {} "needs-reconciliation" if !ok && dispatched && valid_error(result.get("error")) => {} _ => return Err("Unity helper 返回未知或矛盾的执行状态".into()), } } else { if result.get("adapter").and_then(Value::as_str) != Some(UNITY_EDITOR_ADAPTER_ID) { return Err("Unity helper 适配器身份不匹配".into()); } let response_project = result .get("projectPath") .and_then(Value::as_str) .ok_or("Unity helper 缺少项目身份")?; let same = fs::canonicalize(response_project) .ok() .zip(fs::canonicalize(project).ok()) .is_some_and(|(a, b)| a == b); if !same { return Err("Unity helper 返回了其它项目的状态".into()); } let connected = result .get("connected") .and_then(Value::as_bool) .ok_or("Unity helper 缺少连接状态")?; let pid = result.get("pid").ok_or("Unity helper 缺少进程身份")?; if !(pid.is_null() || pid.as_u64().is_some_and(|n| n > 0 && n <= u32::MAX as u64)) { return Err("Unity helper 进程身份无效".into()); } if !result .get("version") .is_some_and(|v| v.is_null() || v.is_string()) { return Err("Unity helper 版本回执无效".into()); } if connected && (pid.is_null() || !nonempty_string(result.get("startedUtc")) || !nonempty_string(result.get("generation"))) { return Err("Unity helper 缺少已连接进程的启动身份或 generation".into()); } if method == "connect" && !connected { return Err("Unity helper 未完成连接握手".into()); } } Ok(result) } fn nonempty_string(value: Option<&Value>) -> bool { value .and_then(Value::as_str) .is_some_and(|s| !s.trim().is_empty()) } fn valid_error(value: Option<&Value>) -> bool { value.is_some_and(|v| nonempty_string(v.get("code")) && nonempty_string(v.get("message"))) } #[derive(Default, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct RpcParams { project_path: Option, process_id: Option, code: Option, timeout_ms: Option, } /// 所有实例只持有配置诊断,实际执行统一进入进程级共享服务。 pub struct UnityEditorAdapter { configuration_error: Option, } impl UnityEditorAdapter { pub fn new(helper_candidates: Vec) -> Self { Self { configuration_error: if helper_candidates.is_empty() { None } else { configure_helper_candidates(helper_candidates).err() }, } } } impl Default for UnityEditorAdapter { fn default() -> Self { Self::new(Vec::new()) } } fn canonical_method(method: &str) -> Result<&str, String> { let method = method.strip_prefix("editor.").unwrap_or(method); match method { "detect" | "connect" | "status" | "execute" | "disconnect" => Ok(method), _ => Err("Unity 适配器不支持该 RPC 方法".into()), } } impl EditorAdapter for UnityEditorAdapter { fn id(&self) -> &'static str { UNITY_EDITOR_ADAPTER_ID } fn detect(&self, project_path: &Path) -> Result { serde_json::from_value(self.rpc("detect", json!({"projectPath": project_path}))?) .map_err(|_| "Unity 探测回执无效".into()) } fn connect( &mut self, pid: u32, project_path: &Path, _version: &str, ) -> Result { serde_json::from_value(self.rpc( "connect", json!({"projectPath":project_path,"processId":pid}), )?) .map_err(|_| "Unity 连接回执无效".into()) } fn disconnect(&mut self) { disconnect_unity_editor(); } fn translate_rpc(&self, method: &str, params: Value) -> Result { Ok(json!({"method":canonical_method(method)?,"params":params})) } fn rpc(&self, method: &str, params: Value) -> Result { let method = canonical_method(method)?; if method == "connect" { disconnect_unity_editor(); } if let Some(error) = &self.configuration_error { return if method == "execute" { execution_result(Err(error.clone())) } else { Err(error.clone()) }; } let result = serde_json::from_value(params) .map_err(|_| "Unity RPC 参数无效或含未允许字段".to_string()) .and_then(|params| service().call(method, params)); if method == "execute" { execution_result(result) } else { result } } } #[cfg(test)] mod tests;