33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。 ## 主要变更 - 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。 - GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。 - 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。 - Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。 ## 验证 - .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。 - Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。 - 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。 Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
508 lines
18 KiB
Rust
508 lines
18 KiB
Rust
//! 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<UnityEditorService> = OnceLock::new();
|
||
SERVICE.get_or_init(UnityEditorService::default)
|
||
}
|
||
|
||
/// 候选路径由宿主内置插件注册流程提供,模型和插件 RPC 不能改写。
|
||
pub fn configure_helper_candidates(candidates: Vec<PathBuf>) -> 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<Value, String> {
|
||
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<Vec<PathBuf>>,
|
||
state: Mutex<ServiceState>,
|
||
epoch: AtomicU64,
|
||
uncertain: AtomicBool,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct ServiceState {
|
||
helper: Option<transport::HelperProcess>,
|
||
epoch: u64,
|
||
next_id: u64,
|
||
}
|
||
|
||
impl UnityEditorService {
|
||
fn configure(&self, candidates: Vec<PathBuf>) -> 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<PathBuf, String> {
|
||
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<Value, String> {
|
||
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<Value, String>,
|
||
) -> Result<Value, String> {
|
||
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<String, String> {
|
||
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<Value, String>) -> Result<Value, String> {
|
||
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<Value, String> {
|
||
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<String>,
|
||
process_id: Option<u32>,
|
||
code: Option<String>,
|
||
timeout_ms: Option<u32>,
|
||
}
|
||
|
||
/// 所有实例只持有配置诊断,实际执行统一进入进程级共享服务。
|
||
pub struct UnityEditorAdapter {
|
||
configuration_error: Option<String>,
|
||
}
|
||
|
||
impl UnityEditorAdapter {
|
||
pub fn new(helper_candidates: Vec<PathBuf>) -> 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<EditorConnectionInfo, String> {
|
||
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<EditorConnectionInfo, String> {
|
||
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<Value, String> {
|
||
Ok(json!({"method":canonical_method(method)?,"params":params}))
|
||
}
|
||
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
|
||
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;
|